// ==UserScript== // @name YouTube コメントと返信を自動展開・翻訳・並び替え ✅ // @name:en YouTube Auto Expand, Translate & Sort Comments ✅ // @name:ja YouTube コメントと返信を自動展開・翻訳・並び替え ✅ // @name:zh-CN YouTube 评论自动展开、翻译与排序 ✅ // @name:zh-TW YouTube 評論自動展開、翻譯與排序 ✅ // @name:ko YouTube 댓글 자동 확장, 번역 및 정렬 ✅ // @name:fr Déploiement, traduction et tri automatique des commentaires YouTube ✅ // @name:es Expansión, traducción y ordenación automática de comentarios de YouTube ✅ // @name:de Automatische Erweiterung, Übersetzung und Sortierung von YouTube-Kommentaren ✅ // @name:pt-BR Expansão, tradução e ordenação automática de comentários do YouTube ✅ // @name:ru Авторазворачивание, перевод и сортировка комментариев YouTube ✅ // @description YouTubeのコメント・返信・他の返信を自動展開し、翻訳ボタンも自動化。並び替え(新しい順)の自動選択も可能です。 // @description:en Automatically expands comments, replies, and "Show more replies". Also auto-translates comments and sorts by "Newest first". // @description:ja YouTubeのコメント・返信・他の返信を自動展開し、翻訳ボタンも自動化。並び替え(新しい順)の自動選択も可能です。 // @description:zh-CN 自动展开评论、回复和“显示更多回复”,自动点击翻译按钮,并可选择自动按“最新”排序。 // @description:zh-TW 自動展開評論、回覆和「顯示更多回覆」,自動點擊翻譯按鈕,並可選擇自動按「最新」排序。 // @description:ko YouTube 댓글, 답글 및 "답글 더보기"를 자동 확장하고 번역 버튼을 클릭하며, "최신순" 자동 정렬 기능을 제공합니다. // @description:fr Déploie automatiquement les commentaires et les réponses. Traduit automatiquement et trie par "Les plus récents". // @description:es Expande automáticamente comentarios y respuestas. Traduce automáticamente y ordena por "Más recientes". // @description:de Erweitert automatisch Kommentare und Antworten. Übersetzt automatisch und sortiert nach "Neueste zuerst". // @description:pt-BR Expande automaticamente comentários e respostas. Traduz automaticamente e ordena por "Mais recentes". // @description:ru Автоматически разворачивает комментарии и ответы. Выполняет автоперевод и сортирует по "Сначала новые". // @version 6.2.0 // @namespace https://github.com/koyasi777/youtube-auto-comment-expander // @author koyasi777 // @match *://www.youtube.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @run-at document-end // @license MIT // @homepageURL https://github.com/koyasi777/youtube-auto-comment-expander // @supportURL https://github.com/koyasi777/youtube-auto-comment-expander/issues // @downloadURL https://update.greasyfork.icu/scripts/533786/YouTube%20%E3%82%B3%E3%83%A1%E3%83%B3%E3%83%88%E3%81%A8%E8%BF%94%E4%BF%A1%E3%82%92%E8%87%AA%E5%8B%95%E5%B1%95%E9%96%8B%E3%83%BB%E7%BF%BB%E8%A8%B3%E3%83%BB%E4%B8%A6%E3%81%B3%E6%9B%BF%E3%81%88%20%E2%9C%85.user.js // @updateURL https://update.greasyfork.icu/scripts/533786/YouTube%20%E3%82%B3%E3%83%A1%E3%83%B3%E3%83%88%E3%81%A8%E8%BF%94%E4%BF%A1%E3%82%92%E8%87%AA%E5%8B%95%E5%B1%95%E9%96%8B%E3%83%BB%E7%BF%BB%E8%A8%B3%E3%83%BB%E4%B8%A6%E3%81%B3%E6%9B%BF%E3%81%88%20%E2%9C%85.meta.js // ==/UserScript== (function() { 'use strict'; /** * Internationalization (i18n) Manager */ const I18n = { languages: [ { code: 'auto', label: 'Auto' }, { code: 'ja', label: '日本語' }, { code: 'en', label: 'English' }, { code: 'zh-CN', label: '简体中文' }, { code: 'zh-TW', label: '繁體中文' }, { code: 'ko', label: '한국어' }, { code: 'fr', label: 'Français' }, { code: 'es', label: 'Español' }, { code: 'de', label: 'Deutsch' }, { code: 'pt-BR', label: 'Português (BR)' }, { code: 'ru', label: 'Русский' } ], messages: { 'ja': { settingsCommand: '⚙️ 設定 (コンソール)', resetCommand: '🗑️ 設定をリセット', resetConfirm: '本当に全ての設定をリセットしますか?', resetComplete: '設定がリセットされました。ページをリロードして反映させてください。', updateComplete: '設定が更新されました。ページをリロードして反映させてください。', modalTitle: 'Auto Expand 設定', lblLanguage: '言語:', tooltipOpenSettings: '詳細設定を開く', tooltipToggle: '自動展開を有効/無効にする', optLongComments: '長いコメントを展開 ("続きを読む")', optReplies: '返信を自動展開', optNestedReplies: '「他の返信を表示」も展開', optAutoTranslate: 'コメントを自動翻訳', optHideOriginal: '└ "原文を見る" を非表示', optSortByNewest: '自動で「新しい順」に並び替え' }, 'en': { settingsCommand: '⚙️ Settings (Console)', resetCommand: '🗑️ Reset Settings', resetConfirm: 'Are you sure you want to reset all settings?', resetComplete: 'Settings reset. Please reload the page.', updateComplete: 'Settings updated. Please reload the page.', modalTitle: 'Auto Expand Settings', lblLanguage: 'Language:', tooltipOpenSettings: 'Open Detailed Settings', tooltipToggle: 'Toggle Auto Expand On/Off', optLongComments: 'Expand long comments ("Read more")', optReplies: 'Auto expand replies', optNestedReplies: 'Expand "Show more replies"', optAutoTranslate: 'Auto translate comments', optHideOriginal: '└ Hide "Show original"', optSortByNewest: 'Auto sort by "Newest first"' }, 'zh-CN': { settingsCommand: '⚙️ 设置 (控制台)', resetCommand: '🗑️ 重置设置', resetConfirm: '确定要重置所有设置吗?', resetComplete: '设置已重置。请刷新页面。', updateComplete: '设置已更新。请刷新页面。', modalTitle: '自动展开设置', lblLanguage: '语言:', tooltipOpenSettings: '打开详细设置', tooltipToggle: '开启/关闭自动展开', optLongComments: '展开长评论 ("阅读更多")', optReplies: '自动展开回复', optNestedReplies: '展开 "显示更多回复"', optAutoTranslate: '自动翻译评论', optHideOriginal: '└ 隐藏 "查看原文"', optSortByNewest: '自动按 "最新" 排序' }, 'zh-TW': { settingsCommand: '⚙️ 設定 (控制台)', resetCommand: '🗑️ 重置設定', resetConfirm: '確定要重置所有設定嗎?', resetComplete: '設定已重置。請重新整理頁面。', updateComplete: '設定已更新。請重新整理頁面。', modalTitle: '自動展開設定', lblLanguage: '語言:', tooltipOpenSettings: '開啟詳細設定', tooltipToggle: '開啟/關閉自動展開', optLongComments: '展開長留言 ("顯示更多")', optReplies: '自動展開回覆', optNestedReplies: '展開 "顯示更多回覆"', optAutoTranslate: '自動翻譯留言', optHideOriginal: '└ 隱藏 "查看原文"', optSortByNewest: '自動按 "最新" 排序' }, 'ko': { settingsCommand: '⚙️ 설정 (콘솔)', resetCommand: '🗑️ 설정 초기화', resetConfirm: '모든 설정을 초기화하시겠습니까?', resetComplete: '설정이 초기화되었습니다. 페이지를 새로 고침하세요.', updateComplete: '설정이 업데이트되었습니다. 페이지를 새로 고침하세요.', modalTitle: '자동 확장 설정', lblLanguage: '언어:', tooltipOpenSettings: '상세 설정 열기', tooltipToggle: '자동 확장 켜기/끄기', optLongComments: '긴 댓글 확장 ("자세히 보기")', optReplies: '답글 자동 확장', optNestedReplies: '"답글 더보기" 확장', optAutoTranslate: '댓글 자동 번역', optHideOriginal: '└ "원본 보기" 숨기기', optSortByNewest: '자동으로 "최신순" 정렬' }, 'fr': { settingsCommand: '⚙️ Paramètres (Console)', resetCommand: '🗑️ Réinitialiser', resetConfirm: 'Voulez-vous vraiment réinitialiser tous les paramètres ?', resetComplete: 'Paramètres réinitialisés. Veuillez recharger la page.', updateComplete: 'Paramètres mis à jour. Veuillez recharger la page.', modalTitle: 'Paramètres Auto Expand', lblLanguage: 'Langue:', tooltipOpenSettings: 'Ouvrir les paramètres détaillés', tooltipToggle: 'Activer/Désactiver l\'extension auto', optLongComments: 'Développer les longs commentaires', optReplies: 'Développer les réponses', optNestedReplies: 'Développer "Afficher d\'autres réponses"', optAutoTranslate: 'Traduire automatiquement', optHideOriginal: '└ Masquer "Voir l\'original"', optSortByNewest: 'Trier auto par "Les plus récents"' }, 'es': { settingsCommand: '⚙️ Configuración (Consola)', resetCommand: '🗑️ Restablecer', resetConfirm: '¿Estás seguro de que deseas restablecer toda la configuración?', resetComplete: 'Configuración restablecida. Por favor, recarga la página.', updateComplete: 'Configuración actualizada. Por favor, recarga la página.', modalTitle: 'Configuración de Expansión', lblLanguage: 'Idioma:', tooltipOpenSettings: 'Abrir configuración detallada', tooltipToggle: 'Activar/Desactivar expansión automática', optLongComments: 'Expandir comentarios largos ("Leer más")', optReplies: 'Expandir respuestas automáticamente', optNestedReplies: 'Expandir "Mostrar más respuestas"', optAutoTranslate: 'Traducir comentarios automáticamente', optHideOriginal: '└ Ocultar "Ver original"', optSortByNewest: 'Ordenar auto por "Más recientes"' }, 'de': { settingsCommand: '⚙️ Einstellungen (Konsole)', resetCommand: '🗑️ Zurücksetzen', resetConfirm: 'Möchten Sie wirklich alle Einstellungen zurücksetzen?', resetComplete: 'Einstellungen zurückgesetzt. Bitte laden Sie die Seite neu.', updateComplete: 'Einstellungen aktualisiert. Bitte laden Sie die Seite neu.', modalTitle: 'Auto Expand Einstellungen', lblLanguage: 'Sprache:', tooltipOpenSettings: 'Detaillierte Einstellungen öffnen', tooltipToggle: 'Autom. Erweitern Ein/Aus', optLongComments: 'Lange Kommentare erweitern ("Mehr anzeigen")', optReplies: 'Antworten automatisch erweitern', optNestedReplies: '"Weitere Antworten" erweitern', optAutoTranslate: 'Kommentare automatisch übersetzen', optHideOriginal: '└ "Original ansehen" ausblenden', optSortByNewest: 'Autom. nach "Neueste zuerst" sortieren' }, 'pt-BR': { settingsCommand: '⚙️ Configurações (Console)', resetCommand: '🗑️ Redefinir', resetConfirm: 'Tem certeza que deseja redefinir todas as configurações?', resetComplete: 'Configurações redefinidas. Por favor, recarregue a página.', updateComplete: 'Configurações atualizadas. Por favor, recarregue a página.', modalTitle: 'Configurações de Expansão', lblLanguage: 'Idioma:', tooltipOpenSettings: 'Abrir configurações detalhadas', tooltipToggle: 'Ativar/Desactivar expansão automática', optLongComments: 'Expandir comentários longos ("Ler mais")', optReplies: 'Expandir respostas automaticamente', optNestedReplies: 'Expandir "Mostrar mais respostas"', optAutoTranslate: 'Traduzir comentários automaticamente', optHideOriginal: '└ Ocultar "Ver original"', optSortByNewest: 'Ordenar auto por "Mais recentes"' }, 'ru': { settingsCommand: '⚙️ Настройки (Консоль)', resetCommand: '🗑️ Сбросить настройки', resetConfirm: 'Вы уверены, что хотите сбросить все настройки?', resetComplete: 'Настройки сброшены. Пожалуйста, перезагрузите страницу.', updateComplete: 'Настройки обновлены. Пожалуйста, перезагрузите страницу.', modalTitle: 'Настройки авторазворачивания', lblLanguage: 'Язык:', tooltipOpenSettings: 'Открыть подробные настройки', tooltipToggle: 'Вкл/Выкл авторазворачивание', optLongComments: 'Разворачивать длинные комментарии ("Читать дальше")', optReplies: 'Автоматически разворачивать ответы', optNestedReplies: 'Разворачивать "Показать другие ответы"', optAutoTranslate: 'Автоперевод комментариев', optHideOriginal: '└ Скрыть "Показать оригинал"', optSortByNewest: 'Автосортировка "Сначала новые"' } }, getCurrentLangCode: function() { if (typeof configManager !== 'undefined') { const userLang = configManager.get('userLanguage'); if (userLang && userLang !== 'auto') return userLang; } return navigator.language || navigator.userLanguage || 'en'; }, t: function(key) { const code = this.getCurrentLangCode(); let dict = this.messages[code] || this.messages[code.slice(0, 2)] || this.messages['en']; return dict[key] || key; } }; class ConfigManager { constructor() { this.defaults = { scriptEnabled: true, userLanguage: 'auto', debugMode: false, initialDelay: 2500, clickInterval: 130, expandLongComments: true, expandReplies: true, expandNestedReplies: true, autoTranslate: true, hideOriginalButton: false, sortByNewest: false }; this.config = {}; this.menuIds = []; this.load(); } load() { for (const key in this.defaults) this.config[key] = GM_getValue(key, this.defaults[key]); } get(key) { return this.config[key]; } set(key, value) { this.config[key] = value; GM_setValue(key, value); } reset() { for (const key in this.defaults) this.set(key, this.defaults[key]); } registerMenu() { if (typeof GM_unregisterMenuCommand === 'function') { this.menuIds.forEach(id => GM_unregisterMenuCommand(id)); this.menuIds = []; } this.menuIds.push(GM_registerMenuCommand(I18n.t('settingsCommand'), () => this.showSettingsPrompt())); this.menuIds.push(GM_registerMenuCommand(I18n.t('resetCommand'), () => { if (confirm(I18n.t('resetConfirm'))) { this.reset(); alert(I18n.t('resetComplete')); location.reload(); } })); } showSettingsPrompt() { const newSettings = {}; for (const key in this.defaults) { const currentValue = this.get(key), type = typeof this.defaults[key]; let newValue = prompt(`${key} (${type}) [Default: ${this.defaults[key]}]\nCurrent: ${currentValue}`, currentValue); if (newValue === null) return; if (type === 'boolean') newSettings[key] = newValue.toLowerCase() === 'true'; else if (type === 'number') { newSettings[key] = parseInt(newValue, 10); if (isNaN(newSettings[key])) newSettings[key] = this.defaults[key]; } else newSettings[key] = newValue; } for (const key in newSettings) this.set(key, newSettings[key]); this.registerMenu(); if (uiManager) uiManager.updateAllText(); alert(I18n.t('updateComplete')); } } class YouTubeCommentExpander { constructor(config) { this.config = config; this.mainObserver = null; this.actionObserver = null; this.readMoreObserver = null; this.sortRetryTimer = null; this.rules = [ { name: 'ExpandReplies', selector: '#more-replies, #more-replies-sub-thread', condition: () => this.config.get('expandReplies') }, { name: 'ExpandNestedReplies', selector: 'ytd-comment-replies-renderer ytd-continuation-item-renderer', condition: () => this.config.get('expandNestedReplies') }, { name: 'AutoTranslate', selector: 'ytd-comment-view-model .translate-button[state="untoggled"]', condition: () => this.config.get('autoTranslate') } ]; } log(level, ...args) { if (!this.config.get('debugMode')) return; console.log(`[YTCE:${level.toUpperCase()}]`, ...args); } setupObservers() { this.actionObserver = new IntersectionObserver(async (entries, observer) => { for (const entry of entries) { if (entry.isIntersecting && this.config.get('scriptEnabled')) { const target = entry.target; observer.unobserve(target); this.log('debug', 'Action target in view, clicking.', target); await new Promise(resolve => setTimeout(resolve, this.config.get('clickInterval'))); const clickable = target.querySelector('button, tp-yt-paper-button') || target.querySelector('yt-button-shape') || target; clickable.click(); } } }, { rootMargin: '0px 0px 500px 0px' }); this.readMoreObserver = new IntersectionObserver(async (entries, observer) => { for (const entry of entries) { if (entry.isIntersecting && this.config.get('scriptEnabled') && this.config.get('expandLongComments')) { const button = entry.target; observer.unobserve(button); this.log('debug', 'ReadMore button in view, clicking.', button); await new Promise(resolve => setTimeout(resolve, this.config.get('clickInterval'))); button.click(); await new Promise(resolve => setTimeout(resolve, 200)); const commentViewModel = button.closest('ytd-comment-view-model, ytd-comment-renderer'); if (commentViewModel) { const lessButton = commentViewModel.querySelector('.less-button, tp-yt-paper-button#less'); if (lessButton) lessButton.style.display = 'none'; } } } }, { threshold: 0.1 }); } observeNewNodes(node) { if (!(node instanceof Element)) return; for (const rule of this.rules) { if (rule.condition()) { if (node.matches(rule.selector)) this.actionObserver.observe(node); node.querySelectorAll(rule.selector).forEach(el => this.actionObserver.observe(el)); } } if (this.readMoreObserver) { const readMoreSelector = 'ytd-expander tp-yt-paper-button#more, .more-button.ytd-comment-view-model'; if (node.matches(readMoreSelector)) this.readMoreObserver.observe(node); node.querySelectorAll(readMoreSelector).forEach(btn => this.readMoreObserver.observe(btn)); } } processExistingNodes(container) { this.log('info', 'Settings changed. Re-processing existing nodes...'); this.observeNewNodes(container); } // ========================================================================= // Enhanced Sort Logic with Retry Mechanism // ========================================================================= initiateSortSequence(container) { if (!this.config.get('scriptEnabled') || !this.config.get('sortByNewest')) return; // Only applicable on Watch page standard comments if (!location.pathname.startsWith('/watch')) return; this.log('info', 'Sort sequence initiated. Polling for sort menu...'); if (this.sortRetryTimer) clearTimeout(this.sortRetryTimer); // Retry for up to 20 attempts (approx 10 seconds) this.performSortAttempt(container, 0, 20); } async performSortAttempt(container, attempt, maxAttempts) { if (attempt >= maxAttempts) { this.log('warn', 'Sort menu never appeared or remained invalid. Giving up.'); return; } const sortMenu = container.querySelector('#sort-menu'); // If sort menu is not found or not visible yet if (!sortMenu || sortMenu.offsetParent === null) { this.sortRetryTimer = setTimeout(() => this.performSortAttempt(container, attempt + 1, maxAttempts), 500); return; } const trigger = sortMenu.querySelector('yt-sort-filter-sub-menu-renderer tp-yt-dropdown-menu #trigger, #trigger'); if (!trigger) { this.sortRetryTimer = setTimeout(() => this.performSortAttempt(container, attempt + 1, maxAttempts), 500); return; } // Trigger found. Now checking state. this.log('debug', `Sort menu found on attempt ${attempt + 1}. Checking state...`); // Step 1: Open menu to populate items (essential for YouTube's lazy polymer) trigger.click(); // Step 2: Short wait for DOM to hydrate items await new Promise(r => setTimeout(r, 100)); const menuList = sortMenu.querySelector('tp-yt-paper-listbox#menu'); if (!menuList) { // Should fail gracefully and close if something is wrong trigger.click(); // close attempts this.sortRetryTimer = setTimeout(() => this.performSortAttempt(container, attempt + 1, maxAttempts), 500); return; } const items = menuList.querySelectorAll('a.yt-simple-endpoint'); if (items.length >= 2) { const newestItem = items[1]; // Usually 2nd option const isSelected = newestItem.classList.contains('iron-selected') || newestItem.querySelector('tp-yt-paper-item.iron-selected'); if (isSelected) { this.log('info', 'Already sorted by Newest. Closing menu.'); // Close the menu by clicking the trigger again or background // Clicking trigger again is safer trigger.click(); } else { this.log('info', 'Sorting by Newest...'); newestItem.click(); // This triggers reload. // No further retries needed as page/comments will reload } } else { // Items not ready? Close and retry. trigger.click(); this.sortRetryTimer = setTimeout(() => this.performSortAttempt(container, attempt + 1, maxAttempts), 500); } } // ========================================================================= start(commentsContainer) { if (!this.config.get('scriptEnabled')) { this.log('info', 'Script is disabled by toggle, not starting.'); return false; } if (!commentsContainer) { this.log('error', 'start() called without a valid container.'); return false; } this.stop(); this.log('info', 'Comment container found. Starting observers.', commentsContainer); // Initiate Sort with Retry Logic this.initiateSortSequence(commentsContainer); this.setupObservers(); this.observeNewNodes(commentsContainer); this.mainObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) this.observeNewNodes(node); } }); this.mainObserver.observe(commentsContainer, { childList: true, subtree: true }); this.log('info', 'All observers started.'); return true; } stop() { if (this.mainObserver) { this.mainObserver.disconnect(); this.mainObserver = null; } if (this.actionObserver) { this.actionObserver.disconnect(); this.actionObserver = null; } if (this.readMoreObserver) { this.readMoreObserver.disconnect(); this.readMoreObserver = null; } if (this.sortRetryTimer) { clearTimeout(this.sortRetryTimer); this.sortRetryTimer = null; } this.log('info', 'All observers stopped and state reset.'); } } class UIManager { constructor(configManager, expander) { this.configManager = configManager; this.expander = expander; this.toggleContainerId = 'ytce-toggle-container'; this.modalId = 'ytce-settings-modal'; this.toggle = null; this.modalElements = {}; this.uiObserver = null; this.pendingWait = null; // 待機プロセス管理用 this.staticIcon = ``; this.injectStyles(); } injectStyles() { GM_addStyle(` /* Toolbar Toggle Container */ #${this.toggleContainerId} { position: relative; display: flex; align-items: center; margin-left: 16px; border: 1px solid var(--yt-spec-mono-10, #ccc); border-radius: 16px; padding: 2px 8px; height: 30px; cursor: default; background-color: var(--yt-spec-badge-chip-background, #f2f2f2); box-shadow: 0 1px 2px rgba(0,0,0,0.05); transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; -webkit-tap-highlight-color: transparent; } /* Shorts specific styles */ ytd-engagement-panel-title-header-renderer #${this.toggleContainerId} { margin-left: 8px; transform: scale(0.9); } #${this.toggleContainerId}:hover { background-color: var(--yt-spec-mono-15, #e0e0e0); } #${this.toggleContainerId}.ytce-active { background-color: var(--yt-spec-badge-chip-background, #f2f2f2); border-color: var(--yt-spec-brand-button-background, #1c62b9); } .ytce-toggle-icon { width: 20px; height: 20px; margin-right: 8px; display: flex; align-items: center; cursor: pointer; border-radius: 50%; padding: 2px; } .ytce-toggle-icon:hover { background-color: rgba(0,0,0,0.1); } .ytce-toggle-icon svg { width: 18px; height: 18px; fill: var(--yt-spec-icon-inactive, #606060); transition: fill 0.2s ease-in-out; } #${this.toggleContainerId}.ytce-active .ytce-toggle-icon svg { fill: var(--yt-spec-brand-button-background, #065fd4); } .ytce-toggle-switch { position: relative; display: inline-block; width: 28px; height: 14px; cursor: pointer; } .ytce-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #aaa; transition: .3s; border-radius: 14px; } .ytce-toggle-slider:before { position: absolute; content: ""; height: 10px; width: 10px; left: 2px; bottom: 2px; background-color: white; transition: .3s; border-radius: 50%; } input:checked + .ytce-toggle-slider { background-color: var(--yt-spec-call-to-action, #065fd4); } input:checked + .ytce-toggle-slider:before { transform: translateX(14px); } #${this.toggleContainerId} .ytce-toggle-switch input { opacity: 0 !important; width: 0 !important; height: 0 !important; position: absolute !important; z-index: -1 !important; pointer-events: none !important; } /* Modal Styles */ .ytce-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 99999; display: none; justify-content: center; align-items: center; backdrop-filter: blur(2px); } .ytce-modal-overlay.visible { display: flex; } .ytce-modal { background: var(--yt-spec-base-background, #fff); color: var(--yt-spec-text-primary, #0f0f0f); width: 450px; max-width: 90%; border-radius: 12px; box-shadow: 0 16px 24px 2px rgba(0, 0, 0, 0.14), 0 6px 30px 5px rgba(0, 0, 0, 0.12), 0 8px 10px -5px rgba(0, 0, 0, 0.2); display: flex; flex-direction: column; border: 1px solid var(--yt-spec-10-percent-layer, #e5e5e5); animation: ytceFadeIn 0.2s ease-out; } @keyframes ytceFadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } .ytce-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--yt-spec-10-percent-layer, #e5e5e5); } .ytce-header-left { display: flex; align-items: center; gap: 12px; } .ytce-modal-title { font-size: 1.8rem; font-weight: 500; color: var(--yt-spec-text-primary, #0f0f0f); margin-right: 12px; } /* Language Label */ .ytce-lang-label { font-size: 1.3rem; color: var(--yt-spec-text-secondary, #606060); margin-right: 4px; } /* Language Select Fixed */ .ytce-lang-select { padding: 4px 8px; border-radius: 4px; border: 1px solid var(--yt-spec-10-percent-layer, #ccc); background-color: var(--yt-spec-menu-background, #fff); color: var(--yt-spec-text-primary, #0f0f0f); font-size: 1.2rem; cursor: pointer; outline: none; } .ytce-lang-select:focus { border-color: var(--yt-spec-call-to-action, #065fd4); } .ytce-lang-select option { background-color: var(--yt-spec-menu-background, #fff); color: var(--yt-spec-text-primary, #0f0f0f); } .ytce-close-btn { background: none; border: none; cursor: pointer; width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s; } .ytce-close-btn:hover { background-color: var(--yt-spec-10-percent-layer, #f2f2f2); } .ytce-close-btn svg { width: 24px; height: 24px; fill: var(--yt-spec-icon-active-other, #606060); } .ytce-modal-content { padding: 8px 0; overflow-y: auto; max-height: 70vh; } .ytce-menu-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; cursor: pointer; user-select: none; transition: background-color 0.1s; } .ytce-menu-item:hover { background-color: var(--yt-spec-10-percent-layer, #f2f2f2); } .ytce-menu-label { flex: 1; margin-right: 12px; font-size: 1.4rem; } .ytce-menu-checkbox { width: 20px; height: 20px; accent-color: var(--yt-spec-call-to-action, #065fd4); cursor: pointer; } body.ytce-hide-original ytd-tri-state-button-view-model.translate-button[state="toggled"] { display: none !important; } `); } createToggleElement() { const existingToggle = document.getElementById(this.toggleContainerId); if (existingToggle) { existingToggle.remove(); } const container = document.createElement('div'); container.id = this.toggleContainerId; const iconDiv = document.createElement('div'); iconDiv.className = 'ytce-toggle-icon'; iconDiv.innerHTML = this.staticIcon; iconDiv.title = I18n.t('tooltipOpenSettings'); iconDiv.onclick = (e) => { e.stopPropagation(); this.openModal(); }; this.modalElements.iconDiv = iconDiv; const switchLabel = document.createElement('label'); switchLabel.className = 'ytce-toggle-switch'; switchLabel.title = I18n.t('tooltipToggle'); this.modalElements.switchLabel = switchLabel; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; const slider = document.createElement('span'); slider.className = 'ytce-toggle-slider'; switchLabel.append(checkbox, slider); checkbox.addEventListener('change', (e) => { e.stopPropagation(); e.target.blur(); this.onMasterToggleChange(checkbox.checked); }); container.append(iconDiv, switchLabel); this.toggle = { container, checkbox, iconDiv }; const initialState = this.configManager.get('scriptEnabled'); checkbox.checked = initialState; this.updateToggleVisuals(initialState); if (this.configManager.get('hideOriginalButton')) { document.body.classList.add('ytce-hide-original'); } this.createModal(); return container; } createModal() { const existingModal = document.getElementById(this.modalId); if (existingModal) { existingModal.remove(); } const overlay = document.createElement('div'); overlay.id = this.modalId; overlay.className = 'ytce-modal-overlay'; overlay.onclick = (e) => { if (e.target === overlay) this.closeModal(); }; const modal = document.createElement('div'); modal.className = 'ytce-modal'; // ヘッダー const header = document.createElement('div'); header.className = 'ytce-modal-header'; const leftGroup = document.createElement('div'); leftGroup.className = 'ytce-header-left'; // タイトル const title = document.createElement('div'); title.className = 'ytce-modal-title'; this.modalElements.title = title; // 言語ラベル const langLabel = document.createElement('span'); langLabel.className = 'ytce-lang-label'; this.modalElements.langLabel = langLabel; // 言語選択 const langSelect = document.createElement('select'); langSelect.className = 'ytce-lang-select'; I18n.languages.forEach(lang => { const option = document.createElement('option'); option.value = lang.code; option.textContent = lang.label; langSelect.appendChild(option); }); langSelect.value = this.configManager.get('userLanguage'); langSelect.onchange = (e) => { this.configManager.set('userLanguage', e.target.value); this.updateAllText(); this.configManager.registerMenu(); this.expander.log('info', `Language changed to: ${e.target.value}`); }; leftGroup.append(title, langLabel, langSelect); const closeBtn = document.createElement('button'); closeBtn.className = 'ytce-close-btn'; closeBtn.innerHTML = ''; closeBtn.onclick = () => this.closeModal(); header.append(leftGroup, closeBtn); // コンテンツ const content = document.createElement('div'); content.className = 'ytce-modal-content'; this.modalElements.content = content; modal.append(header, content); overlay.append(modal); document.body.appendChild(overlay); this.toggle.modalOverlay = overlay; this.renderModalContent(); this.updateAllText(); } renderModalContent() { if (!this.modalElements.content) return; this.modalElements.content.innerHTML = ''; const menuItems = [ { key: 'expandLongComments', label: I18n.t('optLongComments') }, { key: 'expandReplies', label: I18n.t('optReplies') }, { key: 'expandNestedReplies', label: I18n.t('optNestedReplies') }, { key: 'autoTranslate', label: I18n.t('optAutoTranslate') }, { key: 'hideOriginalButton', label: I18n.t('optHideOriginal'), indent: true }, { key: 'sortByNewest', label: I18n.t('optSortByNewest') } ]; menuItems.forEach(item => { const row = document.createElement('div'); row.className = 'ytce-menu-item'; if (item.indent) { row.style.paddingLeft = '48px'; row.style.color = 'var(--yt-spec-text-secondary, #606060)'; } const label = document.createElement('span'); label.className = 'ytce-menu-label'; label.textContent = item.label; const chk = document.createElement('input'); chk.type = 'checkbox'; chk.className = 'ytce-menu-checkbox'; chk.checked = this.configManager.get(item.key); chk.onchange = (e) => { const checked = e.target.checked; this.configManager.set(item.key, checked); if (item.key === 'hideOriginalButton') { document.body.classList.toggle('ytce-hide-original', checked); } else if (checked) { const container = getCurrentCommentsContainer(); if (container) { if (item.key === 'sortByNewest') { this.expander.initiateSortSequence(container); } else { this.expander.processExistingNodes(container); } } } }; row.onclick = (e) => { if (e.target !== chk) { chk.checked = !chk.checked; chk.dispatchEvent(new Event('change')); } }; row.append(label, chk); this.modalElements.content.append(row); }); } updateAllText() { if (this.modalElements.title) this.modalElements.title.textContent = I18n.t('modalTitle'); if (this.modalElements.langLabel) this.modalElements.langLabel.textContent = I18n.t('lblLanguage'); // ラベル更新 if (this.modalElements.iconDiv) this.modalElements.iconDiv.title = I18n.t('tooltipOpenSettings'); if (this.modalElements.switchLabel) this.modalElements.switchLabel.title = I18n.t('tooltipToggle'); this.renderModalContent(); } openModal() { if (this.toggle && this.toggle.modalOverlay) { this.toggle.modalOverlay.classList.add('visible'); } } closeModal() { if (this.toggle && this.toggle.modalOverlay) { this.toggle.modalOverlay.classList.remove('visible'); } } onMasterToggleChange(isEnabled) { this.configManager.set('scriptEnabled', isEnabled); this.updateToggleVisuals(isEnabled); this.expander.log('info', `Script ${isEnabled ? 'enabled' : 'disabled'} by toggle.`); if (isEnabled) { const commentsContainer = getCurrentCommentsContainer(); if (commentsContainer) this.expander.start(commentsContainer); } else { this.expander.stop(); } } updateToggleVisuals(isEnabled) { if (!this.toggle) return; this.toggle.container.classList.toggle('ytce-active', isEnabled); } observeCommentsHeader(containerSelector, sortMenuSelector, sortMenuLabelSelector, insertMode) { // 前の待機処理をキャンセル(重複防止) if (this.pendingWait) { this.pendingWait.abort(); } // コンテナが出現するのを待機 this.pendingWait = waitForElement(containerSelector, (container) => { this.pendingWait = null; // コンテナが見つかったので、ここでExpanderも起動する (Logicの一元化) if (this.configManager.get('scriptEnabled')) { this.expander.start(container); } this.stopUIObserver(); // 既存のUI監視があれば停止 // UIの注入処理 const updateUI = () => this.updateCommentsHeaderUI(container, sortMenuSelector, sortMenuLabelSelector, insertMode); this.uiObserver = new MutationObserver(updateUI); this.uiObserver.observe(container, { childList: true, subtree: true }); updateUI(); this.expander.log('info', `UI Observer started for "${containerSelector}".`); }); } updateCommentsHeaderUI(container, sortMenuSelector, sortMenuLabelSelector, insertMode) { // Find the Sort Menu WITHIN the specific container const sortMenu = container.querySelector(sortMenuSelector); if (!sortMenu) return; if (!document.getElementById(this.toggleContainerId)) { const toggleElement = this.createToggleElement(); if (toggleElement) { if (insertMode === 'append') { sortMenu.parentElement.appendChild(toggleElement); } else if (insertMode === 'after') { sortMenu.insertAdjacentElement('afterend', toggleElement); } this.expander.log('debug', 'Toggle UI injected.'); } } // Hide the label if needed (for Watch page mainly) if (sortMenuLabelSelector) { const label = container.querySelector(sortMenuLabelSelector); if (label && label.style.display !== 'none') { label.style.display = 'none'; this.expander.log('debug', 'Sort menu label hidden.'); } } } initForWatchPage() { this.observeCommentsHeader( 'ytd-comments#comments', '#sort-menu', '#sort-menu #icon-label', 'append' ); } initForShortsPage() { // Shorts uses engagement panel this.observeCommentsHeader( 'ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-comments-section"]', 'ytd-engagement-panel-title-header-renderer #menu', // Insert relative to the menu container null, // No label to hide in shorts typically or selector is different 'after' // Insert after the #menu div (between Sort and Close button) ); } stopUIObserver() { if (this.uiObserver) { this.uiObserver.disconnect(); this.uiObserver = null; this.expander.log('info', 'UI Observer stopped.'); } } stop() { // 待機中の検索を中止 (waitForElementのキャンセル) if (this.pendingWait) { this.pendingWait.abort(); this.pendingWait = null; } this.stopUIObserver(); const modal = document.getElementById(this.modalId); if (modal) modal.remove(); const toggle = document.getElementById(this.toggleContainerId); if (toggle) toggle.remove(); } } const configManager = new ConfigManager(); let expander = null; let uiManager = null; let currentPath = ''; let initTimer = null; // Timer ID for debounce/cleanup function waitForElement(selector, callback, timeout = 15000) { let timeoutId = null; let observer = null; let isAborted = false; const abort = () => { if (isAborted) return; isAborted = true; if (timeoutId) clearTimeout(timeoutId); if (observer) { observer.disconnect(); observer = null; } }; // 即時チェック const element = document.querySelector(selector); if (element) { callback(element); return { abort: () => {} }; } observer = new MutationObserver((mutations) => { if (isAborted) return; const el = document.querySelector(selector); if (el) { abort(); callback(el); } }); observer.observe(document.body, { childList: true, subtree: true }); timeoutId = setTimeout(() => { if (!isAborted) { if (typeof expander !== 'undefined' && expander) { expander.log('warn', `waitForElement timed out for selector: ${selector}`); } abort(); } }, timeout); return { abort }; } function getCurrentCommentsContainer() { if (location.pathname.startsWith('/watch')) { return document.querySelector('ytd-comments#comments'); } else if (location.pathname.startsWith('/shorts/')) { return document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-comments-section"]'); } return null; } function initializeScript() { const path = location.pathname + location.search; if (currentPath === path && expander) return; currentPath = path; // 前の初期化待機をキャンセル if (initTimer) { clearTimeout(initTimer); initTimer = null; } // 既存インスタンスの完全破棄 if (expander) { expander.stop(); expander = null; } if (uiManager) { uiManager.stop(); // ここで waitForElement も abort される uiManager = null; } expander = new YouTubeCommentExpander(configManager); uiManager = new UIManager(configManager, expander); configManager.registerMenu(); initTimer = setTimeout(() => { initTimer = null; if (location.pathname.startsWith('/shorts/')) { expander.log('info', 'Shorts page detected. Initializing...'); // UIManagerに処理を委譲 (内部で waitForElement -> expander.start を実行) uiManager.initForShortsPage(); } else if (location.pathname.startsWith('/watch')) { expander.log('info', 'Watch page detected. Initializing...'); uiManager.initForWatchPage(); } else { expander.log('info', 'Not a watch/shorts page. Script is idle.'); } }, configManager.get('initialDelay')); } window.addEventListener('yt-navigate-finish', initializeScript, true); initializeScript(); })();