Greasy Fork is available in English.
Filter YouTube videos by channel, keyword, duration, language, live streams and members-only content. Full blocklist/whitelist management with quick action buttons on video cards.
// ==UserScript==
// @name YouTube Channel Filter
// @namespace http://tampermonkey.net/
// @version 1.1.1-rc1
// @description Filter YouTube videos by channel, keyword, duration, language, live streams and members-only content. Full blocklist/whitelist management with quick action buttons on video cards.
// @author MayoHu
// @match https://www.youtube.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @license MIT
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// DEBUG
// ============================================================
// 使用方式(任選一種):
// A. Tampermonkey 圖示選單:點擊「🔍 切換 Debug 模式」
// B. Console 輸入:window.__YTF_DEBUG = true 然後 window.__ytfReport()
//
// 腳本在 sandbox 執行(因 @grant),必須透過 unsafeWindow 共享頁面變數,
// 否則用戶在頁面 Console 輸入的 window.xxx 無法被腳本讀到。
var _pageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
var _dbgOn = false; // 腳本內部旗標,同步於 _pageWin.__YTF_DEBUG
var _ytfStats = {
obsTotalRuns: 0,
obsCardsSelected: 0,
processOneCalls: 0,
processOneSkipped_noInfo: 0,
processOneSkipped_memberWait: 0,
processOneHidden: 0,
processOnePass: 0,
qaCalls: 0,
qaSkipped_alreadyMarked: 0,
qaSkipped_noInfo: 0,
qaInjected: 0,
selectorSamples: {},
checkReasons: {}
};
// 暴露到頁面 window(Console 可直接存取)
try {
_pageWin.__ytfStats = _ytfStats;
_pageWin.__ytfReport = function() {
try { console.table(_ytfStats); } catch(e){ console.log('[YTF]', _ytfStats); }
};
// 讓用戶可以在 console 切換 debug:透過 getter/setter 同步腳本內的 _dbgOn
Object.defineProperty(_pageWin, '__YTF_DEBUG', {
configurable: true,
get: function(){ return _dbgOn; },
set: function(v){ _dbgOn = !!v; console.log('[YTF] Debug mode:', _dbgOn); }
});
} catch(e){ console.warn('[YTF] unsafeWindow bridge failed:', e); }
function ftDbg(){
if(!_dbgOn) return;
try { console.log.apply(console, ['[YTF]'].concat(Array.prototype.slice.call(arguments))); } catch(e){}
}
function ftCount(key){
if(typeof _ytfStats[key] === 'number') _ytfStats[key]++;
}
// Tampermonkey 選單按鈕
try {
if(typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand(t('tm.debugToggle'), function(){
_dbgOn = !_dbgOn;
try { _pageWin.__YTF_DEBUG = _dbgOn; } catch(e){}
console.log('[YTF] Debug mode:', _dbgOn, t('debug.hint'));
});
GM_registerMenuCommand(t('tm.debugStats'), function(){
try { console.table(_ytfStats); } catch(e){ console.log('[YTF]', _ytfStats); }
});
}
} catch(e){}
// ============================================================
// DOM HELPER — 完全不使用 innerHTML,CSP 相容
// ============================================================
// h(tag, {attr:val, cls:'...', on:{click:fn}}, [...children | 'text'])
function h(tag, attrs, children) {
var el = document.createElement(tag);
attrs = attrs || {};
Object.keys(attrs).forEach(function(k) {
if (k === 'cls') {
el.className = attrs[k];
} else if (k === 'on') {
Object.keys(attrs[k]).forEach(function(ev) {
el.addEventListener(ev, attrs[k][ev]);
});
} else if (k === 'style') {
el.style.cssText = attrs[k];
} else {
el.setAttribute(k, attrs[k]);
}
});
var childArr = Array.isArray(children) ? children : (children !== null && children !== undefined ? [children] : []);
childArr.forEach(function(c) {
if (c === null || c === undefined) return;
if (typeof c === 'string' || typeof c === 'number') {
el.appendChild(document.createTextNode(String(c)));
} else if (c.nodeType) {
el.appendChild(c);
}
});
return el;
}
// 清空並重新填入子元素
function setChildren(parent, children) {
while (parent.firstChild) parent.removeChild(parent.firstChild);
var childArr = Array.isArray(children) ? children : (children !== null && children !== undefined ? [children] : []);
childArr.forEach(function(c) {
if (!c) return;
if (typeof c === 'string') parent.appendChild(document.createTextNode(c));
else if (c.nodeType) parent.appendChild(c);
});
}
// ============================================================
// I18N (介面多語言)
// ============================================================
// 字串對照表:i18n.zh / i18n.en 之下以語意化 key 儲存
// t(key) 讀取當前 LANG 對應值,找不到時回退英文,再找不到回傳 key 本身
var i18n = { zh: {}, en: {} };
// ── C-2: 核心訊息(Notif / QA / FAB) ─────────────────────
i18n.zh['reason.unknown'] = '未知';
i18n.zh['reason.keyword'] = '關鍵字「'; // 後綴 + 值 + 」
i18n.zh['reason.keyword.suffix']= '」';
i18n.zh['reason.channel'] = '頻道隱藏';
i18n.zh['reason.language'] = '語言('; // 後綴 + 值 + )
i18n.zh['reason.language.suffix']= ')';
i18n.zh['reason.duration'] = '時長過濾';
i18n.zh['reason.views'] = '觀看數過濾';
i18n.zh['reason.date'] = '日期過濾';
i18n.zh['reason.live'] = '直播過濾';
i18n.zh['reason.upcoming'] = '首播過濾';
i18n.zh['reason.members_only'] = '會員專屬';
i18n.zh['notif.hidden.prefix'] = '已隱藏 ';
i18n.zh['notif.hidden.suffix'] = ' 部影片,觸發:';
i18n.zh['qa.channelTip'] = '頻道:';
i18n.zh['qa.block'] = '🚫 隱藏此頻道';
i18n.zh['qa.whitelist'] = '⭐ 加入白名單';
i18n.zh['qa.blockTitle'] = '隱藏頻道:';
i18n.zh['qa.whitelistTitle'] = '加入白名單:';
i18n.zh['qa.hidden'] = '✅ 已隱藏';
i18n.zh['qa.whitelisted'] = '✅ 已加入白名單';
i18n.zh['qa.already.block'] = '已隱藏頻道:';
i18n.zh['qa.already.white'] = '已加入白名單:';
i18n.zh['qa.conflict.blockToWL']= '」已在白名單';
i18n.zh['qa.conflict.WLToBlock']= '」已在隱藏名單';
i18n.zh['qa.quote.open'] = '「';
i18n.zh['qa.hiddenPrefix'] = '已隱藏:';
i18n.zh['fab.title'] = 'YouTube 頻道過濾器 - 點擊開啟 / 右鍵切換';
i18n.zh['fab.tip.enabled'] = 'YouTube 頻道過濾器(啟用中)\n左鍵:開啟面板 | 右鍵:暫停';
i18n.zh['fab.tip.disabled'] = 'YouTube 頻道過濾器(已暫停)\n左鍵:開啟面板 | 右鍵:啟用';
i18n.zh['filter.enabled'] = '過濾器已啟用';
i18n.zh['filter.disabled'] = '過濾器已暫停';
i18n.zh['notif.separator'] = '、';
// ── C-3: Panel UI ─────────────────────────────────────
i18n.zh['panel.title'] = 'YouTube 頻道過濾器';
i18n.zh['tab.blocklist'] = '🚫 隱藏名單';
i18n.zh['tab.whitelist'] = '⭐ 白名單';
i18n.zh['tab.keywords'] = '🔤 關鍵字';
i18n.zh['tab.filters'] = '⚙️ 設定';
i18n.zh['tab.stats'] = '📊 統計';
i18n.zh['tab.history'] = '📋 歷史';
i18n.zh['tab.io'] = '💾 匯出入';
i18n.zh['tab.about'] = '📖 使用說明';
i18n.zh['panel.close'] = '關閉';
i18n.zh['common.confirm'] = '確認';
i18n.zh['common.cancel'] = '取消';
i18n.zh['common.save'] = '儲存';
i18n.zh['common.remove'] = '移除';
i18n.zh['common.edit'] = '編輯';
i18n.zh['common.add'] = '新增';
i18n.zh['common.create'] = '建立';
i18n.zh['common.copy'] = '複製';
i18n.zh['common.skip'] = '略過';
i18n.zh['common.all'] = '全部';
i18n.zh['common.up'] = '上移';
i18n.zh['common.down'] = '下移';
i18n.zh['common.mode'] = '模式';
i18n.zh['common.total'] = '共 ';
i18n.zh['common.items'] = ' 筆';
i18n.zh['common.times'] = ' 次';
i18n.zh['common.groups'] = ' 群組';
i18n.zh['common.channels'] = '個頻道';
i18n.zh['channel.searchPlaceholder'] = '搜尋頻道...';
i18n.zh['channel.name'] = '頻道名稱';
i18n.zh['channel.handle'] = '頻道 ID(選填,如 @handle)';
i18n.zh['channel.handleShort'] = '@handle(選填)';
i18n.zh['channel.addPrompt'] = '請輸入頻道名稱';
i18n.zh['channel.empty.blocklist'] = '尚無頻道';
i18n.zh['channel.tagsHint'] = '標籤(逗號分隔,選填)';
i18n.zh['channel.clearAll'] = '🗑 清空全部';
i18n.zh['kw.newGroup'] = '+ 新增群組';
i18n.zh['kw.delGroup'] = '🗑 刪除群組';
i18n.zh['kw.groupName'] = '請輸入群組名稱';
i18n.zh['kw.groupNamePh'] = '群組名稱';
i18n.zh['kw.kwPlaceholder'] = '關鍵字';
i18n.zh['kw.addKwPrompt'] = '請輸入關鍵字';
i18n.zh['kw.scope.title'] = ' 標題';
i18n.zh['kw.scope.description'] = ' 描述';
i18n.zh['kw.noGroups'] = '尚無群組';
i18n.zh['filter.wlOnly'] = '⭐ 只顯示白名單頻道';
i18n.zh['filter.wlOnly.hint'] = '啟用後,僅顯示白名單內的頻道,其餘全部隱藏';
i18n.zh['filter.duration'] = '⏱ 影片時長';
i18n.zh['filter.duration.min'] = '最短(秒)';
i18n.zh['filter.duration.max'] = '最長(秒,0=不限)';
i18n.zh['filter.live'] = '📡 直播 / 首播';
i18n.zh['filter.live.hideLive'] = ' 隱藏直播中';
i18n.zh['filter.live.hideUpcoming'] = ' 隱藏即將首播';
i18n.zh['filter.member'] = '👑 會員專屬';
i18n.zh['filter.views'] = '👁 觀看數';
i18n.zh['filter.views.min'] = '最少觀看';
i18n.zh['filter.views.max'] = '最多觀看(0=不限)';
i18n.zh['filter.views.hint'] = '直接輸入數字:10000(一萬)/ 1000000(百萬)。直播的「N 人正在觀看」不計入;部分卡片無觀看數時略過';
i18n.zh['filter.date'] = '📅 上傳日期';
i18n.zh['filter.date.hide'] = '隱藏';
i18n.zh['filter.date.hint'] = '首頁顯示相對時間(精度:天),觀看頁側邊欄可取得精確日期';
i18n.zh['filter.lang'] = '🌐 語言過濾';
i18n.zh['filter.lang.modeBlock'] = '過濾指定語言';
i18n.zh['filter.lang.modeAllow'] = '僅顯示指定語言';
i18n.zh['filter.lang.custom'] = '其他語言代碼(逗號分隔,如 nl, pl, sv)';
i18n.zh['filter.lang.ref'] = '🔗 BCP-47 語言代碼參考';
i18n.zh['filter.lang.refTip'] = '開啟 IANA 語言代碼查詢';
i18n.zh['filter.saveBtn'] = '💾 儲存設定';
i18n.zh['filter.savedToast'] = '設定已儲存';
i18n.zh['lang.zh'] = '中文(含所有變體)';
i18n.zh['lang.en'] = '🇺🇸 英文';
i18n.zh['lang.ja'] = '🇯🇵 日文';
i18n.zh['lang.ko'] = '🇰🇷 韓文';
i18n.zh['lang.th'] = '🇹🇭 泰文';
i18n.zh['lang.vi'] = '🇻🇳 越南文';
i18n.zh['lang.id'] = '🇮🇩 印尼文';
i18n.zh['lang.es'] = '🇪🇸 西班牙文';
i18n.zh['lang.pt'] = '🇵🇹 葡萄牙文';
i18n.zh['lang.fr'] = '🇫🇷 法文';
i18n.zh['lang.de'] = '🇩🇪 德文';
i18n.zh['lang.ar'] = '🇸🇦 阿拉伯文';
i18n.zh['lang.ru'] = '🇷🇺 俄文';
i18n.zh['lang.hi'] = '🇮🇳 印地文';
i18n.zh['lang.tr'] = '🇹🇷 土耳其文';
i18n.zh['lang.it'] = '🇮🇹 義大利文';
i18n.zh['set.global'] = '⚙️ 全域設定';
i18n.zh['set.toast'] = ' 顯示操作通知(Toast)';
i18n.zh['set.toastHint'] = '關閉後,操作通知仍會寫入面板頂部記錄,但不會彈出';
i18n.zh['set.stats'] = ' 顯示統計與歷史 Tab';
i18n.zh['set.statsHint'] = '關閉後 Tab 隱藏,但後台繼續記錄,可在匯出入頁獨立匯出';
i18n.zh['set.lang'] = '🌐 介面語言 / Interface';
i18n.zh['set.lang.zh'] = '中文(繁體)';
i18n.zh['set.lang.switched'] = '語言已切換 / Language changed';
i18n.zh['stat.totalHidden'] = '累計隱藏';
i18n.zh['stat.topKw'] = '🔤 關鍵字命中排行';
i18n.zh['stat.topCh'] = '🚫 頻道命中排行';
i18n.zh['stat.noData'] = '尚無資料';
i18n.zh['stat.clear'] = '🗑 清空統計';
i18n.zh['stat.clearConfirm'] = '確定清空所有統計資料?';
i18n.zh['stat.cleared'] = '統計已清空';
i18n.zh['hist.empty'] = '尚無記錄';
i18n.zh['hist.clear'] = '🗑 清空歷史';
i18n.zh['hist.clearConfirm'] = '確定清空所有歷史記錄?';
i18n.zh['hist.cleared'] = '歷史已清空';
i18n.zh['io.backup'] = '💾 設定備份';
i18n.zh['io.backupDesc'] = '備份隱藏名單、白名單、關鍵字群組、設定等完整資料。';
i18n.zh['io.stat.section'] = '📊 統計與歷史獨立備份';
i18n.zh['io.statHint'] = '統計與歷史不納入完整設定備份,請使用下方按鈕獨立匯出。';
i18n.zh['io.exportJson'] = '📥 匯出 JSON';
i18n.zh['io.importJson'] = '📤 匯入 JSON';
i18n.zh['io.csvHint'] = 'CSV 格式可用試算表軟體開啟編輯,再匯入回腳本。';
i18n.zh['io.mergeMode'] = '匯入模式:';
i18n.zh['io.merge'] = '合併';
i18n.zh['io.replace'] = '覆蓋';
i18n.zh['io.share'] = '🔗 分享清單';
i18n.zh['io.shareDesc'] = '產生分享連結,讓他人直接匯入你的過濾設定。';
i18n.zh['io.shareBlock'] = '分享隱藏名單';
i18n.zh['io.shareWhite'] = '分享白名單';
i18n.zh['io.shareKw'] = '分享關鍵字';
i18n.zh['io.copied'] = '已複製到剪貼簿';
i18n.zh['io.genLinkFail'] = '產生連結失敗';
i18n.zh['io.linkTooLong'] = '⚠️ 連結過長(';
i18n.zh['io.linkTooLongSuffix'] = ' 字元),建議改用下方 JSON 備份匯出';
i18n.zh['io.sharedImported'] = '分享清單已匯入';
i18n.zh['io.backupFilename'] = '備份檔名(選填)';
i18n.zh['io.backupFilenameHint'] = '留空則使用 yt-filter-backup(固定檔名覆蓋)';
i18n.zh['about.software'] = '📋 軟體資訊';
i18n.zh['about.name'] = '名稱';
i18n.zh['about.version'] = '版本';
i18n.zh['about.compat'] = '相容性';
i18n.zh['about.author'] = '作者';
i18n.zh['about.source'] = '原始碼';
i18n.zh['about.donate'] = '☕ 支持開發者';
i18n.zh['about.donateText'] = '本專案採用自願訂閱模式。如果您認為這個工具有價值,並且每月能輕鬆捐贈幾枚金幣,歡迎支持我持續維護與開發。您的支持是最大的動力!';
i18n.zh['toast.channelEmpty'] = '名稱不可為空';
i18n.zh['channel.manageTags'] = '管理標籤';
i18n.zh['channel.removeTag'] = '移除標籤';
i18n.zh['channel.confirmRemove'] = '確認移除';
i18n.zh['channel.regexMode'] = ' ⌥R.* Regex 模式(比對頻道名稱)';
i18n.zh['channel.regexHint'] = '輸入 pattern 即可,不需加 / 或 /i,預設不分大小寫';
i18n.zh['channel.cleared.block'] = '已清空隱藏名單';
i18n.zh['channel.cleared.white'] = '已清空白名單';
i18n.zh['io.shareHint2'] = '對方在 YouTube 頁面開啟此連結後,腳本會自動偵測並提示匯入';
i18n.zh['io.autoBackup'] = ' 啟用自動備份';
i18n.zh['io.backupInterval'] = '備份間隔(天)';
i18n.zh['io.backupPrefixHint'] = '指定前綴後,檔名為「前綴-日期.json」;留空則每次覆蓋同一檔案';
i18n.zh['help.language2'] = '偵測方式:根據標題字元集自動判斷語系。拉丁語系(越南文、法文等)因字元重疊偵測精度較低,建議搭配關鍵字過濾補充。';
i18n.zh['channel.clearBlockConfirm'] = '確定清空所有隱藏頻道(共 ';
i18n.zh['channel.clearWhiteConfirm'] = '確定清空所有白名單頻道(共 ';
i18n.zh['common.countSuffix'] = ' 個)?';
i18n.zh['channel.confirmRemovePrefix'] = '確定移除「';
i18n.zh['channel.confirmRemoveSuffix'] = '」?';
i18n.zh['kw.confirmDelPrefix'] = '確定刪除群組「';
i18n.zh['kw.confirmDelSuffix2'] = '」?';
i18n.zh['toast.added'] = '已新增:';
i18n.zh['toast.updated'] = '已更新:';
i18n.zh['io.label.blocklist'] = '隱藏名單:';
i18n.zh['io.label.whitelist'] = '白名單:';
i18n.zh['io.label.keywords'] = '關鍵字:';
i18n.zh['kw.countSuffix'] = '個關鍵字';
i18n.zh['channel.tagFilter.all'] = '全部(';
i18n.zh['channel.tagFilter.allSuffix'] = ')';
i18n.zh['hist.unknownTitle'] = '未知標題';
i18n.zh['channel.noResults'] = '無符合結果';
i18n.zh['kw.addKwBtn'] = '+ 關鍵字';
i18n.zh['channel.newTagPh'] = '新標籤名稱,Enter 確認';
i18n.zh['channel.editRegexMode'] = ' ⌥R.* Regex 模式';
i18n.zh['filter.lang.customLabel'] = '自訂其他語言代碼';
// ── D-5: 重寫 makeAbout 新增 key ─────────────────
i18n.zh['help.quickStart.title'] = '🚀 快速入門';
i18n.zh['help.quickStart.step1'] = '① 隱藏頻道:滑鼠移到影片卡片上,點擊底部「🚫 隱藏此頻道」即可永久隱藏該頻道所有影片';
i18n.zh['help.quickStart.step2'] = '② 白名單:點擊「⭐ 加入白名單」,該頻道將永遠顯示(不受任何過濾規則影響)';
i18n.zh['help.quickStart.step3'] = '③ 關鍵字過濾:在 🔤 關鍵字 Tab 建立群組並加入字詞,標題或描述包含該字詞的影片會被隱藏';
i18n.zh['help.quickStart.step4'] = '④ 過濾器開關:右鍵點擊頂欄 🔍 按鈕可暫停 / 啟用整個過濾器(臨時查看所有影片時很方便)';
i18n.zh['help.regex.title'] = '🔍 Regex 模式完整指南';
i18n.zh['help.regex.intro'] = '在「頻道隱藏/白名單」和「關鍵字群組」勾選「⌥R.* Regex 模式」後,可用正規表達式精確比對。預設不分大小寫。';
i18n.zh['help.regex.syntax.title'] = '常用語法';
i18n.zh['help.regex.syntax.1'] = '• ^abc — 以 abc 開頭';
i18n.zh['help.regex.syntax.2'] = '• abc$ — 以 abc 結尾';
i18n.zh['help.regex.syntax.3'] = '• a|b — a 或 b(擇一)';
i18n.zh['help.regex.syntax.4'] = '• \\d+ — 一個或多個數字';
i18n.zh['help.regex.syntax.5'] = '• \\b — 單詞邊界(避免部分匹配)';
i18n.zh['help.regex.syntax.6'] = '• .* — 任意字元(零或多個)';
i18n.zh['help.regex.syntax.7'] = '• [0-9] — 字元範圍(如 0 到 9)';
i18n.zh['help.regex.examples.title'] = '實用範例';
i18n.zh['help.regex.examples.1'] = '• ^\\[業配\\] — 所有「[業配]」開頭的影片';
i18n.zh['help.regex.examples.2'] = '• \\bNASA\\b — 獨立出現 NASA 的影片(避開 "NASA-related")';
i18n.zh['help.regex.examples.3'] = '• (股票|投資|理財) — 提到股票、投資、理財任一關鍵字';
i18n.zh['help.regex.examples.4'] = '• EP\\.?\\d+ — EP1, EP.1, EP 1 等格式的集數(頻道名用於過濾特定系列)';
i18n.zh['help.regex.examples.5'] = '• ^(?!.*官方).*$ — 標題不包含「官方」的所有影片(負向先行斷言)';
i18n.zh['help.regex.tip'] = '💡 提示:不需要加 / 或 /i 標記,直接輸入 pattern 即可;反斜線在 JS 需要跳脫 \\\\,但本介面會自動處理。';
i18n.zh['help.kids.title'] = '🛡️ 兒童保護設定範例';
i18n.zh['help.kids.intro'] = '以下組合利用腳本現有功能,打造一個「白名單專享 + 危險內容自動過濾」的兒童友善瀏覽環境。';
i18n.zh['help.kids.step1'] = '第 1 步|白名單:到 ⭐ 白名單 Tab 加入孩子可看的頻道(例如 @PBSKids、@SesameStreet、@CocomelonOfficial)';
i18n.zh['help.kids.step2'] = '第 2 步|白名單獨享:到 ⚙️ 設定 最頂部,勾選「⭐ 只顯示白名單頻道」— 其他所有頻道都會被隱藏';
i18n.zh['help.kids.step3'] = '第 3 步|禁直播:勾選「📡 直播 / 首播」區塊內的「隱藏直播中」和「隱藏即將首播」— 避免直播聊天室風險';
i18n.zh['help.kids.step4'] = '第 4 步|關鍵字黑名單:到 🔤 關鍵字 建立「危險內容」群組,加入如 暴力、血腥、恐怖、成人、賭博 等字詞並啟用(可用 Regex:(暴力|血腥|恐怖|成人))';
i18n.zh['help.kids.step5'] = '第 5 步(可選)|限制日期:到 📅 上傳日期 設定「隱藏 30 天前上傳的影片」— 避免演算法推送陳年爭議內容';
i18n.zh['help.kids.step6'] = '第 6 步(可選)|語言過濾:到 🌐 語言過濾 選擇「僅顯示指定語言」並只勾選孩子的母語';
i18n.zh['help.kids.warning'] = '⚠️ 重要警語:此腳本僅過濾首頁 / 搜尋 / 推薦等列表頁,無法阻止直接輸入影片 URL,也無法偵測影片實際內容。強烈建議搭配 YouTube Kids、家長監護軟體(Google Family Link 等)、或路由器層級的 DNS 過濾同時使用。';
i18n.zh['help.shortcuts.title'] = '⌨️ 快捷操作';
i18n.zh['help.shortcuts.fab'] = '頂欄 🔍 按鈕:左鍵開啟面板,右鍵切換過濾器開關';
i18n.zh['help.shortcuts.cardBar'] = '影片卡片快捷列:滑鼠懸停時底部滑出「🚫 隱藏」和「⭐ 白名單」按鈕';
i18n.zh['help.shortcuts.tmMenu'] = 'Tampermonkey 選單:瀏覽器擴充圖示也提供「開啟面板」「切換過濾器」選項';
i18n.zh['io.toast.exported.json'] = '已匯出設定 JSON';
i18n.zh['io.toast.exported.bl'] = '已匯出隱藏名單 CSV';
i18n.zh['io.toast.exported.wl'] = '已匯出白名單 CSV';
i18n.zh['io.toast.exported.kw'] = '已匯出關鍵字 CSV';
i18n.zh['io.toast.exported.stats'] = '已匯出統計資料 JSON';
i18n.zh['io.toast.exported.hist'] = '已匯出歷史記錄 CSV';
i18n.zh['io.toast.importFailed'] = '匯入失敗:';
i18n.zh['io.err.format'] = '格式不符';
i18n.zh['io.toast.imported.overwrite'] = '已覆蓋匯入設定';
i18n.zh['io.toast.imported.prefix'] = '已匯入設定(新增 ';
i18n.zh['io.toast.imported.blPrefix'] = '已匯入隱藏名單:新增 ';
i18n.zh['io.toast.imported.kwPrefix'] = '已匯入關鍵字:新增 ';
i18n.zh['io.toast.imported.suffixDup'] = ' 筆(重複)';
i18n.zh['io.toast.imported.skip'] = ' / 略過 ';
i18n.zh['io.toast.imported.suffixEnd'] = ' 筆)';
i18n.zh['io.toast.autoBackupDone'] = '自動備份完成:';
i18n.zh['io.toast.shareReceived'] = '收到分享清單(';
i18n.zh['io.toast.shareReceivedEnd'] = '),請在管理面板 > 匯出入 中匯入';
i18n.zh['io.label.groups'] = '關鍵字群組:';
i18n.zh['common.groupSuffix'] = '個群組';
i18n.zh['common.countOnly'] = '個';
i18n.zh['tm.togglePanel'] = '⚙️ 開啟管理面板';
i18n.zh['tm.toggleFilter'] = '🔄 切換過濾器';
i18n.zh['tm.backupNow'] = '💾 立即備份設定';
i18n.zh['tm.debugToggle'] = '🔍 切換 Debug 模式';
i18n.zh['tm.debugStats'] = '📊 顯示 Debug 統計';
i18n.zh['debug.hint'] = '— 執行 __ytfReport() 查看統計';
i18n.zh['csv.header.channelId'] = '頻道ID';
i18n.zh['csv.header.channelName'] = '頻道名稱';
i18n.zh['csv.header.tags'] = '標籤';
i18n.zh['csv.header.groupName'] = '群組名稱';
i18n.zh['csv.header.keyword'] = '關鍵字';
i18n.zh['csv.header.regex'] = 'Regex';
i18n.zh['csv.header.scope'] = '範圍';
i18n.zh['csv.header.enabled'] = '啟用';
i18n.zh['csv.header.title'] = '標題';
i18n.zh['csv.header.reason'] = '觸發原因';
i18n.zh['csv.header.time'] = '時間';
i18n.zh['kw.searchPh'] = '搜尋群組名或關鍵字...';
i18n.zh['channel.noExistingTags'] = '目前沒有可選的標籤';
i18n.zh['channel.addTagLabel'] = '新增標籤';
i18n.zh['channel.existingTagsLabel'] = '所有標籤(✓已擁有、點擊切換)';
i18n.zh['kw.groupNameLabel'] = '群組名稱';
i18n.zh['kw.scopeLabel'] = '套用範圍';
i18n.zh['kw.scopeRequired'] = '至少需要選一個範圍(標題或描述)';
i18n.zh['filter.date.hideOlder'] = '比設定值老的(舊片)';
i18n.zh['filter.date.hideNewer'] = '比設定值新的(新片)';
i18n.zh['filter.date.unit.day'] = '天';
i18n.zh['filter.date.unit.year'] = '年';
i18n.en['reason.unknown'] = 'Unknown';
i18n.en['reason.keyword'] = 'Keyword: ';
i18n.en['reason.keyword.suffix']= '';
i18n.en['reason.channel'] = 'Channel blocked';
i18n.en['reason.language'] = 'Language (';
i18n.en['reason.language.suffix']= ')';
i18n.en['reason.duration'] = 'Duration filter';
i18n.en['reason.views'] = 'View count filter';
i18n.en['reason.date'] = 'Upload date filter';
i18n.en['reason.live'] = 'Live stream';
i18n.en['reason.upcoming'] = 'Upcoming premiere';
i18n.en['reason.members_only'] = 'Members only';
i18n.en['notif.hidden.prefix'] = 'Hidden ';
i18n.en['notif.hidden.suffix'] = ' videos — reason: ';
i18n.en['qa.channelTip'] = 'Channel: ';
i18n.en['qa.block'] = '🚫 Block channel';
i18n.en['qa.whitelist'] = '⭐ Whitelist';
i18n.en['qa.blockTitle'] = 'Block channel: ';
i18n.en['qa.whitelistTitle'] = 'Whitelist: ';
i18n.en['qa.hidden'] = '✅ Hidden';
i18n.en['qa.whitelisted'] = '✅ Whitelisted';
i18n.en['qa.already.block'] = 'Already blocked: ';
i18n.en['qa.already.white'] = 'Already whitelisted: ';
i18n.en['qa.conflict.blockToWL']= '» is already in blocklist';
i18n.en['qa.conflict.WLToBlock']= '» is already whitelisted';
i18n.en['qa.hiddenPrefix'] = 'Hidden: ';
i18n.en['fab.title'] = 'YouTube Channel Filter — click to open / right-click to toggle';
i18n.en['fab.tip.enabled'] = 'YouTube Channel Filter (active)\nLeft-click: open panel | Right-click: pause';
i18n.en['fab.tip.disabled'] = 'YouTube Channel Filter (paused)\nLeft-click: open panel | Right-click: resume';
i18n.en['filter.enabled'] = 'Filter enabled';
i18n.en['filter.disabled'] = 'Filter paused';
i18n.en['notif.separator'] = ', ';
i18n.en['qa.quote.open'] = '«';
// ── C-3: Panel UI (EN) ────────────────────────────────
i18n.en['panel.title'] = 'YouTube Channel Filter';
i18n.en['tab.blocklist'] = '🚫 Blocklist';
i18n.en['tab.whitelist'] = '⭐ Whitelist';
i18n.en['tab.keywords'] = '🔤 Keywords';
i18n.en['tab.filters'] = '⚙️ Settings';
i18n.en['tab.stats'] = '📊 Stats';
i18n.en['tab.history'] = '📋 History';
i18n.en['tab.io'] = '💾 I/O';
i18n.en['tab.about'] = '📖 Help';
i18n.en['panel.close'] = 'Close';
i18n.en['common.confirm'] = 'Confirm';
i18n.en['common.cancel'] = 'Cancel';
i18n.en['common.save'] = 'Save';
i18n.en['common.remove'] = 'Remove';
i18n.en['common.edit'] = 'Edit';
i18n.en['common.add'] = 'Add';
i18n.en['common.create'] = 'Create';
i18n.en['common.copy'] = 'Copy';
i18n.en['common.skip'] = 'Skip';
i18n.en['common.all'] = 'All';
i18n.en['common.up'] = 'Up';
i18n.en['common.down'] = 'Down';
i18n.en['common.mode'] = 'Mode';
i18n.en['common.total'] = 'Total ';
i18n.en['common.items'] = ' items';
i18n.en['common.times'] = ' times';
i18n.en['common.groups'] = ' groups';
i18n.en['common.channels'] = ' channels';
i18n.en['channel.searchPlaceholder'] = 'Search channels...';
i18n.en['channel.name'] = 'Channel name';
i18n.en['channel.handle'] = 'Channel ID (optional, e.g. @handle)';
i18n.en['channel.handleShort'] = '@handle (optional)';
i18n.en['channel.addPrompt'] = 'Please enter a channel name';
i18n.en['channel.regexMode'] = ' ⌥R.* Regex mode (match channel name)';
i18n.en['channel.regexHint'] = 'Enter pattern only, no slashes or flags. Case-insensitive by default.';
i18n.en['channel.empty.blocklist'] = 'No channels yet';
i18n.en['channel.tagsHint'] = 'Tags (comma-separated, optional)';
i18n.en['channel.manageTags'] = 'Manage tags';
i18n.en['channel.removeTag'] = 'Remove tag';
i18n.en['channel.confirmRemove'] = 'Confirm removal';
i18n.en['channel.clearAll'] = '🗑 Clear all';
i18n.en['channel.cleared.block'] = 'Blocklist cleared';
i18n.en['channel.cleared.white'] = 'Whitelist cleared';
i18n.en['kw.newGroup'] = '+ New group';
i18n.en['kw.delGroup'] = '🗑 Delete group';
i18n.en['kw.groupName'] = 'Please enter a group name';
i18n.en['kw.groupNamePh'] = 'Group name';
i18n.en['kw.kwPlaceholder'] = 'Keyword';
i18n.en['kw.addKwPrompt'] = 'Please enter a keyword';
i18n.en['kw.scope.title'] = ' Title';
i18n.en['kw.scope.description'] = ' Description';
i18n.en['kw.noGroups'] = 'No groups yet';
i18n.en['filter.wlOnly'] = '⭐ Show only whitelisted channels';
i18n.en['filter.wlOnly.hint'] = 'When enabled, only whitelisted channels are shown; all others are hidden.';
i18n.en['filter.duration'] = '⏱ Video duration';
i18n.en['filter.duration.min'] = 'Minimum (seconds)';
i18n.en['filter.duration.max'] = 'Maximum (seconds, 0 = unlimited)';
i18n.en['filter.live'] = '📡 Live / Premieres';
i18n.en['filter.live.hideLive'] = ' Hide live streams';
i18n.en['filter.live.hideUpcoming'] = ' Hide upcoming premieres';
i18n.en['filter.member'] = '👑 Members-only';
i18n.en['filter.views'] = '👁 View count';
i18n.en['filter.views.min'] = 'Minimum views';
i18n.en['filter.views.max'] = 'Maximum views (0 = unlimited)';
i18n.en['filter.views.hint'] = 'Enter numbers directly: 10000 (10K) / 1000000 (1M). Live-stream viewer counts are not counted; cards without a view count are skipped.';
i18n.en['filter.date'] = '📅 Upload date';
i18n.en['filter.date.hide'] = 'Hide';
i18n.en['filter.date.hint'] = 'Example: choose «Older than» + 4 years = hide videos older than 4 years.';
i18n.en['filter.lang'] = '🌐 Language filter';
i18n.en['filter.lang.modeBlock'] = 'Block selected languages';
i18n.en['filter.lang.modeAllow'] = 'Show only selected languages';
i18n.en['filter.lang.custom'] = 'Other language codes (comma-separated, e.g. nl, pl, sv)';
i18n.en['filter.lang.ref'] = '🔗 BCP-47 language code reference';
i18n.en['filter.lang.refTip'] = 'Open IANA language subtag lookup';
i18n.en['filter.saveBtn'] = '💾 Save settings';
i18n.en['filter.savedToast'] = 'Settings saved';
i18n.en['lang.zh'] = 'Chinese (all variants)';
i18n.en['lang.en'] = '🇺🇸 English';
i18n.en['lang.ja'] = '🇯🇵 Japanese';
i18n.en['lang.ko'] = '🇰🇷 Korean';
i18n.en['lang.th'] = '🇹🇭 Thai';
i18n.en['lang.vi'] = '🇻🇳 Vietnamese';
i18n.en['lang.id'] = '🇮🇩 Indonesian';
i18n.en['lang.es'] = '🇪🇸 Spanish';
i18n.en['lang.pt'] = '🇵🇹 Portuguese';
i18n.en['lang.fr'] = '🇫🇷 French';
i18n.en['lang.de'] = '🇩🇪 German';
i18n.en['lang.ar'] = '🇸🇦 Arabic';
i18n.en['lang.ru'] = '🇷🇺 Russian';
i18n.en['lang.hi'] = '🇮🇳 Hindi';
i18n.en['lang.tr'] = '🇹🇷 Turkish';
i18n.en['lang.it'] = '🇮🇹 Italian';
i18n.en['set.global'] = '⚙️ Global settings';
i18n.en['set.toast'] = ' Show operation toasts';
i18n.en['set.toastHint'] = 'When disabled, toasts are silent but still logged in the panel.';
i18n.en['set.stats'] = ' Show Stats & History tabs';
i18n.en['set.statsHint'] = 'When disabled, tabs are hidden but tracking continues. Export separately in Import/Export.';
i18n.en['set.lang'] = '🌐 Interface language / 介面語言';
i18n.en['set.lang.zh'] = 'Chinese (Traditional)';
i18n.en['set.lang.switched'] = 'Language changed / 語言已切換';
i18n.en['stat.totalHidden'] = 'Total hidden';
i18n.en['stat.topKw'] = '🔤 Top keywords';
i18n.en['stat.topCh'] = '🚫 Top channels';
i18n.en['stat.noData'] = 'No data yet';
i18n.en['stat.clear'] = '🗑 Clear stats';
i18n.en['stat.clearConfirm'] = 'Clear all stats?';
i18n.en['stat.cleared'] = 'Stats cleared';
i18n.en['hist.empty'] = 'No entries yet';
i18n.en['hist.clear'] = '🗑 Clear history';
i18n.en['hist.clearConfirm'] = 'Clear all history?';
i18n.en['hist.cleared'] = 'History cleared';
i18n.en['io.backup'] = '💾 Settings backup';
i18n.en['io.backupDesc'] = 'Backup blocklist, whitelist, keyword groups, filter settings, and preferences.';
i18n.en['io.stat.section'] = '📊 Stats & History standalone backup';
i18n.en['io.statHint'] = 'Stats and history are not included in the full backup. Use the buttons below to export them separately.';
i18n.en['io.exportJson'] = '📥 Export JSON';
i18n.en['io.importJson'] = '📤 Import JSON';
i18n.en['io.csvHint'] = 'CSV can be edited in spreadsheet software and imported back.';
i18n.en['io.mergeMode'] = 'Import mode: ';
i18n.en['io.merge'] = 'Merge';
i18n.en['io.replace'] = 'Replace';
i18n.en['io.share'] = '🔗 Share list';
i18n.en['io.shareDesc'] = 'Generate a share link so others can import your filter config directly.';
i18n.en['io.shareBlock'] = 'Share blocklist';
i18n.en['io.shareWhite'] = 'Share whitelist';
i18n.en['io.shareKw'] = 'Share keywords';
i18n.en['io.copied'] = 'Copied to clipboard';
i18n.en['io.genLinkFail'] = 'Failed to generate link';
i18n.en['io.linkTooLong'] = '⚠️ Link too long (';
i18n.en['io.linkTooLongSuffix'] = ' chars). Use JSON backup instead.';
i18n.en['io.sharedImported'] = 'Shared list imported';
i18n.en['io.backupFilename'] = 'Backup filename (optional)';
i18n.en['io.backupFilenameHint'] = 'Leave blank to use yt-filter-backup (overwrites same file).';
i18n.en['about.software'] = '📋 Software info';
i18n.en['about.name'] = 'Name';
i18n.en['about.version'] = 'Version';
i18n.en['about.compat'] = 'Compatibility';
i18n.en['about.author'] = 'Author';
i18n.en['about.source'] = 'Source code';
i18n.en['about.donate'] = '☕ Support the developer';
i18n.en['about.donateText'] = 'This project runs on voluntary support. If you find this tool valuable and can spare a few coins each month, your sponsorship helps me keep maintaining and improving it. Thank you!';
i18n.en['toast.channelEmpty'] = 'Name cannot be empty';
i18n.en['io.shareHint2'] = 'When the recipient opens this link on YouTube, the script will detect and prompt for import.';
i18n.en['io.autoBackup'] = ' Enable auto-backup';
i18n.en['io.backupInterval'] = 'Backup interval (days)';
i18n.en['io.backupPrefixHint'] = 'With a prefix, filename becomes «prefix-date.json». Blank overwrites the same file.';
i18n.en['help.language2'] = 'Detection uses title charset. Latin-based languages (Vietnamese, French etc.) have lower detection accuracy due to character overlap—combine with keyword filters for better coverage.';
i18n.en['channel.clearBlockConfirm'] = 'Clear all blocked channels (total ';
i18n.en['channel.clearWhiteConfirm'] = 'Clear all whitelisted channels (total ';
i18n.en['common.countSuffix'] = ' items)?';
i18n.en['channel.confirmRemovePrefix'] = 'Remove «';
i18n.en['channel.confirmRemoveSuffix'] = '» ?';
i18n.en['kw.confirmDelPrefix'] = 'Delete group «';
i18n.en['kw.confirmDelSuffix2'] = '» ?';
i18n.en['toast.added'] = 'Added: ';
i18n.en['toast.updated'] = 'Updated: ';
i18n.en['io.label.blocklist'] = 'Blocklist: ';
i18n.en['io.label.whitelist'] = 'Whitelist: ';
i18n.en['io.label.keywords'] = 'Keywords: ';
i18n.en['kw.countSuffix'] = ' keywords';
i18n.en['channel.tagFilter.all'] = 'All (';
i18n.en['channel.tagFilter.allSuffix'] = ')';
i18n.en['hist.unknownTitle'] = 'Untitled';
i18n.en['channel.noResults'] = 'No matching results';
i18n.en['kw.addKwBtn'] = '+ Keyword';
i18n.en['channel.newTagPh'] = 'New tag, press Enter to confirm';
i18n.en['channel.editRegexMode'] = ' ⌥R.* Regex mode';
i18n.en['filter.lang.customLabel'] = 'Custom language codes';
// ── D-5: makeAbout new keys (EN) ──────────────────
i18n.en['help.quickStart.title'] = '🚀 Quick Start';
i18n.en['help.quickStart.step1'] = '① Block a channel: Hover a video card and click «🚫 Block channel» at the bottom to permanently hide all videos from that channel.';
i18n.en['help.quickStart.step2'] = '② Whitelist: Click «⭐ Whitelist» to always show that channel (bypassing all filter rules).';
i18n.en['help.quickStart.step3'] = '③ Keyword filter: Go to 🔤 Keywords, create a group, and add terms. Videos whose title or description contains those terms will be hidden.';
i18n.en['help.quickStart.step4'] = '④ Toggle filter: Right-click the 🔍 button in the top bar to pause/resume the whole filter (handy when you want to browse without filters).';
i18n.en['help.regex.title'] = '🔍 Regex Mode Complete Guide';
i18n.en['help.regex.intro'] = 'Enable «⌥R.* Regex mode» in Block/Whitelist entries or Keyword groups to use regular expressions for precise matching. Case-insensitive by default.';
i18n.en['help.regex.syntax.title'] = 'Common syntax';
i18n.en['help.regex.syntax.1'] = '• ^abc — starts with abc';
i18n.en['help.regex.syntax.2'] = '• abc$ — ends with abc';
i18n.en['help.regex.syntax.3'] = '• a|b — a OR b';
i18n.en['help.regex.syntax.4'] = '• \\d+ — one or more digits';
i18n.en['help.regex.syntax.5'] = '• \\b — word boundary (avoids partial match)';
i18n.en['help.regex.syntax.6'] = '• .* — any characters (zero or more)';
i18n.en['help.regex.syntax.7'] = '• [0-9] — character range (e.g. 0 to 9)';
i18n.en['help.regex.examples.title'] = 'Practical examples';
i18n.en['help.regex.examples.1'] = '• ^\\[Ad\\] — all videos starting with «[Ad]»';
i18n.en['help.regex.examples.2'] = '• \\bNASA\\b — standalone NASA (avoids matches like «NASA-related»)';
i18n.en['help.regex.examples.3'] = '• (stocks|investing|finance) — mentions any of these keywords';
i18n.en['help.regex.examples.4'] = '• EP\\.?\\d+ — matches EP1, EP.1, EP 1 etc. (useful for filtering specific series)';
i18n.en['help.regex.examples.5'] = '• ^(?!.*official).*$ — titles NOT containing «official» (negative lookahead)';
i18n.en['help.regex.tip'] = '💡 Tip: no need for / slashes or /i flags — just enter the pattern. Backslashes are handled automatically by the UI.';
i18n.en['help.kids.title'] = '🛡️ Child Safety Setup Example';
i18n.en['help.kids.intro'] = 'The following combo uses the script\'s existing features to build a «whitelist-only + auto-filter harmful content» child-friendly browsing setup.';
i18n.en['help.kids.step1'] = 'Step 1 | Whitelist: Go to the ⭐ Whitelist tab and add child-safe channels (e.g. @PBSKids, @SesameStreet, @CocomelonOfficial).';
i18n.en['help.kids.step2'] = 'Step 2 | Whitelist-only: Go to ⚙️ Settings (top) and tick «⭐ Show only whitelisted channels» — all other channels will be hidden.';
i18n.en['help.kids.step3'] = 'Step 3 | No live streams: In «📡 Live / Premieres», enable «Hide live streams» and «Hide upcoming premieres» — avoids live-chat risks.';
i18n.en['help.kids.step4'] = 'Step 4 | Keyword blocklist: In 🔤 Keywords, create a «Harmful content» group with words like violence, gore, horror, adult, gambling and enable it (regex: (violence|gore|horror|adult|gambling)).';
i18n.en['help.kids.step5'] = 'Step 5 (optional) | Date limit: In 📅 Upload date, set «Hide videos older than 30 days» — avoids the algorithm pushing old controversial content.';
i18n.en['help.kids.step6'] = 'Step 6 (optional) | Language filter: In 🌐 Language filter, choose «Show only selected languages» and tick only the child\'s native language.';
i18n.en['help.kids.warning'] = '⚠️ Important disclaimer: This script only filters listing pages (home / search / recommendations). It cannot block direct URL access, nor detect actual video content. Strongly recommend combining with YouTube Kids, parental control software (Google Family Link, etc.), or router-level DNS filtering.';
i18n.en['help.shortcuts.title'] = '⌨️ Quick Actions';
i18n.en['help.shortcuts.fab'] = 'Top bar 🔍 button: left-click opens panel, right-click toggles filter on/off.';
i18n.en['help.shortcuts.cardBar'] = 'Card quick-bar: hover any video card to reveal «🚫 Block» and «⭐ Whitelist» buttons at the bottom.';
i18n.en['help.shortcuts.tmMenu'] = 'Tampermonkey menu: the browser extension icon also provides «Open panel» and «Toggle filter» options.';
i18n.en['io.toast.exported.json'] = 'Exported settings JSON';
i18n.en['io.toast.exported.bl'] = 'Exported blocklist CSV';
i18n.en['io.toast.exported.wl'] = 'Exported whitelist CSV';
i18n.en['io.toast.exported.kw'] = 'Exported keywords CSV';
i18n.en['io.toast.exported.stats'] = 'Exported stats JSON';
i18n.en['io.toast.exported.hist'] = 'Exported history CSV';
i18n.en['io.toast.importFailed'] = 'Import failed: ';
i18n.en['io.err.format'] = 'Format mismatch';
i18n.en['io.toast.imported.overwrite'] = 'Settings imported (overwritten)';
i18n.en['io.toast.imported.prefix'] = 'Settings imported (added ';
i18n.en['io.toast.imported.blPrefix'] = 'Blocklist imported: added ';
i18n.en['io.toast.imported.kwPrefix'] = 'Keywords imported: added ';
i18n.en['io.toast.imported.suffixDup'] = ' items (duplicates)';
i18n.en['io.toast.imported.skip'] = ' / skipped ';
i18n.en['io.toast.imported.suffixEnd'] = ' items)';
i18n.en['io.toast.autoBackupDone'] = 'Auto-backup complete: ';
i18n.en['io.toast.shareReceived'] = 'Received shared list (';
i18n.en['io.toast.shareReceivedEnd'] = '), please import from Panel > Import/Export';
i18n.en['io.label.groups'] = 'Keyword groups: ';
i18n.en['common.groupSuffix'] = ' groups';
i18n.en['common.countOnly'] = ' items';
i18n.en['tm.togglePanel'] = '⚙️ Open management panel';
i18n.en['tm.toggleFilter'] = '🔄 Toggle filter';
i18n.en['tm.backupNow'] = '💾 Backup settings now';
i18n.en['tm.debugToggle'] = '🔍 Toggle debug mode';
i18n.en['tm.debugStats'] = '📊 Show debug stats';
i18n.en['debug.hint'] = '— run __ytfReport() to view stats';
i18n.en['csv.header.channelId'] = 'Channel ID';
i18n.en['csv.header.channelName'] = 'Channel Name';
i18n.en['csv.header.tags'] = 'Tags';
i18n.en['csv.header.groupName'] = 'Group Name';
i18n.en['csv.header.keyword'] = 'Keyword';
i18n.en['csv.header.regex'] = 'Regex';
i18n.en['csv.header.scope'] = 'Scope';
i18n.en['csv.header.enabled'] = 'Enabled';
i18n.en['csv.header.title'] = 'Title';
i18n.en['csv.header.reason'] = 'Trigger Reason';
i18n.en['csv.header.time'] = 'Time';
i18n.en['kw.searchPh'] = 'Search groups or keywords...';
i18n.en['channel.noExistingTags'] = 'No existing tags available';
i18n.en['channel.addTagLabel'] = 'Add tag';
i18n.en['channel.existingTagsLabel'] = 'All tags (✓owned, click to toggle)';
i18n.en['kw.groupNameLabel'] = 'Group name';
i18n.en['kw.scopeLabel'] = 'Apply to';
i18n.en['kw.scopeRequired'] = 'At least one scope is required (title or description)';
i18n.en['filter.date.hideOlder'] = 'Older than (old videos)';
i18n.en['filter.date.hideNewer'] = 'Newer than (recent videos)';
i18n.en['filter.date.unit.day'] = 'days';
i18n.en['filter.date.unit.year'] = 'years';
var LANG = (function() {
try {
var raw = GM_getValue('ytf_lang', null);
if (raw === 'zh' || raw === 'en') return raw;
} catch(e) {}
// v1.1.0 預設英文(使用者可透過設定或 Tampermonkey 選單切換為中文)
return 'en';
})();
function t(key) {
var tbl = i18n[LANG] || i18n.en || {};
if (tbl[key] !== undefined) return tbl[key];
if (i18n.en && i18n.en[key] !== undefined) return i18n.en[key];
return key;
}
function setLang(newLang) {
if (newLang !== 'zh' && newLang !== 'en') return;
LANG = newLang;
try { GM_setValue('ytf_lang', newLang); } catch(e) {}
}
// ============================================================
// STORAGE
// ============================================================
// ── Module-level Regex cache(S 和 Filter 共用,設定更新時由 Filter.reset() 清除)──
var _reCache = new Map();
function compiledRe(pat) {
if(!_reCache.has(pat)){
try{_reCache.set(pat,new RegExp(pat,'i'));}
catch(e){_reCache.set(pat,null);}
}
return _reCache.get(pat);
}
var S = (function() {
function jget(k, def) {
try { var v = GM_getValue(k, null); return v === null ? def : JSON.parse(v); } catch(e) { return def; }
}
function jset(k, v) { try { GM_setValue(k, JSON.stringify(v)); } catch(e) {} }
// ── 批次寫入(addStat / addHistory 高頻呼叫時 debounce)──
var _pendingStat=null, _pendingHist=null;
var _statTimer=null, _histTimer=null;
function _flushStat(){ if(_pendingStat!==null){jset('ytf_st',_pendingStat);_pendingStat=null;} }
function _flushHist(){ if(_pendingHist!==null){jset('ytf_hi',_pendingHist);_pendingHist=null;} }
// 頁面隱藏時強制 flush,避免資料遺失
document.addEventListener('visibilitychange',function(){ if(document.hidden){_flushStat();_flushHist();} });
var defFilters = { duration:{enabled:false,min:0,max:0}, live:{enabled:false,hideLive:false,hideUpcoming:false}, member:{enabled:false}, language:{enabled:false,mode:'block',langs:[]}, whitelistOnly:false, uploadDate:{enabled:false,condition:'older',days:365}, views:{enabled:false,min:0,max:0} };
function getBlocklist() { return jget('ytf_bl', []); }
function setBlocklist(v) { jset('ytf_bl', v); }
function getWhitelist() { return jget('ytf_wl', []); }
function setWhitelist(v) { jset('ytf_wl', v); }
function getKeyGroups() { return jget('ytf_kg', []); }
function setKeyGroups(v) { jset('ytf_kg', v); }
function getFilters() { return jget('ytf_fs', defFilters); }
function setFilters(v) { jset('ytf_fs', v); }
function getStats() { return jget('ytf_st', {totalHidden:0,keywordHits:{},channelHits:{}}); }
function setStats(v) { jset('ytf_st', v); }
function getHistory() { return jget('ytf_hi', []); }
function setHistory(v) { jset('ytf_hi', v); }
function getGlobal() { return jget('ytf_gl', {enabled:true,autoBackup:false,backupInterval:7,lastBackup:null,showToast:true,enableStats:true}); }
function setGlobal(v) { jset('ytf_gl', v); }
function isEnabled() { return getGlobal().enabled !== false; }
function setEnabled(v) { var g=getGlobal(); g.enabled=v; setGlobal(g); }
function addToBlocklist(ch) {
var l=getBlocklist();
if (l.find(function(c){ return (ch.id&&c.id===ch.id)||(ch.name&&c.name===ch.name&&!ch.id); })) return false;
l.push({id:ch.id||'',name:ch.name||'',isRegex:!!ch.isRegex,tags:ch.tags||[],addedAt:Date.now()});
setBlocklist(l); return true;
}
function removeFromBlocklist(key) { setBlocklist(getBlocklist().filter(function(c){ return c.id!==key&&c.name!==key; })); }
function updateBlocklistEntry(key, upd) {
setBlocklist(getBlocklist().map(function(c){
if(c.id===key||c.name===key) return Object.assign({},c,upd);
return c;
}));
}
function isBlocked(id,name) {
return getBlocklist().some(function(c){
if(c.isRegex&&c.name&&name){var _re=compiledRe(c.name);return _re?_re.test(name):false;}
return (id&&c.id&&c.id===id)||(name&&c.name&&c.name===name);
});
}
function addToWhitelist(ch) {
var l=getWhitelist();
if (l.find(function(c){ return (ch.id&&c.id===ch.id)||(ch.name&&c.name===ch.name); })) return false;
// v1.1.0: 支援 tags 欄位(選填)
l.push({id:ch.id||'',name:ch.name||'',isRegex:!!ch.isRegex,tags:ch.tags||[],addedAt:Date.now()});
setWhitelist(l); return true;
}
function removeFromWhitelist(key) { setWhitelist(getWhitelist().filter(function(c){ return c.id!==key&&c.name!==key; })); }
function updateWhitelistEntry(key, upd) {
setWhitelist(getWhitelist().map(function(c){
if(c.id===key||c.name===key) return Object.assign({},c,upd);
return c;
}));
}
function isWhitelisted(id,name) {
return getWhitelist().some(function(c){
if(c.isRegex&&c.name&&name){var _re=compiledRe(c.name);return _re?_re.test(name):false;}
return (id&&c.id&&c.id===id)||(name&&c.name&&c.name===name);
});
}
function addKeyGroup(g) {
var groups=getKeyGroups();
var ng={id:'g'+Date.now(),name:g.name||'未命名',enabled:true,keywords:g.keywords||[],scope:g.scope||['title']};
groups.push(ng); setKeyGroups(groups); return ng;
}
function updateKeyGroup(id,upd) { setKeyGroups(getKeyGroups().map(function(g){ return g.id===id?Object.assign({},g,upd):g; })); }
function removeKeyGroup(id) { setKeyGroups(getKeyGroups().filter(function(g){ return g.id!==id; })); }
function moveKeyGroup(id, dir) {
var gs = getKeyGroups();
var idx = gs.findIndex(function(g){ return g.id===id; });
if(idx<0) return;
var newIdx = dir==='up' ? idx-1 : idx+1;
if(newIdx<0||newIdx>=gs.length) return;
var tmp = gs[idx]; gs[idx] = gs[newIdx]; gs[newIdx] = tmp;
setKeyGroups(gs);
}
function addStat(reason,chId,chName) {
if(_pendingStat===null) _pendingStat=getStats();
var s=_pendingStat; s.totalHidden=(s.totalHidden||0)+1;
if (reason&&reason.indexOf('keyword:')===0){var kw=reason.slice(8);s.keywordHits[kw]=(s.keywordHits[kw]||0)+1;}
if (chId) {
s.channelHits[chId]=(s.channelHits[chId]||0)+1;
if(chName){if(!s.channelNames)s.channelNames={};s.channelNames[chId]=chName;}
}
clearTimeout(_statTimer); _statTimer=setTimeout(_flushStat,1000);
}
function resetStats() { setStats({totalHidden:0,keywordHits:{},channelHits:{}}); }
function addHistory(entry) {
if(_pendingHist===null) _pendingHist=getHistory();
var hh=_pendingHist; hh.unshift(Object.assign({},entry,{hiddenAt:Date.now()}));
if(hh.length>500) hh.pop();
clearTimeout(_histTimer); _histTimer=setTimeout(_flushHist,1000);
}
function clearHistory() { setHistory([]); }
function exportAll() {
// 統計與歷史不納入完整備份,只能透過獨立按鈕匯出
return {version:'2.1',exportedAt:Date.now(),blocklist:getBlocklist(),whitelist:getWhitelist(),keyGroups:getKeyGroups(),filters:getFilters(),global:getGlobal()};
}
function exportStats() { return {version:'1.0',exportedAt:Date.now(),stats:getStats()}; }
function exportHistoryCSV() {
var rows=[[t('csv.header.title'),t('csv.header.channelName'),t('csv.header.channelId'),t('csv.header.reason'),t('csv.header.time')]];
getHistory().forEach(function(r){
rows.push([
(r.title||'').replace(/,/g,','),
(r.channelName||'').replace(/,/g,','),
(r.channelId||'').replace(/,/g,','),
(r.reason||'').replace(/,/g,','),
r.hiddenAt?new Date(r.hiddenAt).toLocaleString():''
]);
});
return rows.map(function(r){return r.join(',');}).join('\n');
}
function importAll(data,mode) {
if (!data||(data.version!=='2.1'&&data.version!=='2.0')) throw new Error(t('io.err.format'));
if (mode==='overwrite'){
if(data.blocklist)setBlocklist(data.blocklist);
if(data.whitelist)setWhitelist(data.whitelist);
if(data.keyGroups)setKeyGroups(data.keyGroups);
if(data.filters)setFilters(data.filters);
} else {
if(data.blocklist){var bl=getBlocklist();data.blocklist.forEach(function(c){if(!bl.find(function(x){return x.id===c.id;}))bl.push(c);});setBlocklist(bl);}
if(data.whitelist){var wl=getWhitelist();data.whitelist.forEach(function(c){if(!wl.find(function(x){return x.id===c.id;}))wl.push(c);});setWhitelist(wl);}
if(data.keyGroups){var kg=getKeyGroups();data.keyGroups.forEach(function(g){if(!kg.find(function(x){return x.id===g.id;}))kg.push(g);});setKeyGroups(kg);}
}
}
// 標籤管理:取得不重複標籤(v1.1.0: 修訂:支援 type 隔離 blocklist/whitelist)
// type: 'block' 只掃隱藏名單 / 'white' 只掃白名單 / 不傳 = 兩者合計(向後相容)
function getAllTags(type) {
var tags = {};
if(type==='block' || !type) {
getBlocklist().forEach(function(c){
(c.tags||[]).forEach(function(t){ if(t) tags[t] = (tags[t]||0)+1; });
});
}
if(type==='white' || !type) {
getWhitelist().forEach(function(c){
(c.tags||[]).forEach(function(t){ if(t) tags[t] = (tags[t]||0)+1; });
});
}
return Object.keys(tags).sort().map(function(t){ return {name:t, count:tags[t]}; });
}
// 更新某頻道的標籤
function updateBlocklistTags(key, tags) {
var l = getBlocklist();
var found = false;
l = l.map(function(c){
if(c.id===key || c.name===key){ found=true; return Object.assign({},c,{tags:tags}); }
return c;
});
if(found){ setBlocklist(l); return true; }
return false;
}
// v1.1.0: 更新白名單某頻道的標籤
function updateWhitelistTags(key, tags) {
var l = getWhitelist();
var found = false;
l = l.map(function(c){
if(c.id===key || c.name===key){ found=true; return Object.assign({},c,{tags:tags}); }
return c;
});
if(found){ setWhitelist(l); return true; }
return false;
}
return {
getBlocklist,setBlocklist,addToBlocklist,removeFromBlocklist,updateBlocklistEntry,isBlocked,
getWhitelist,setWhitelist,addToWhitelist,removeFromWhitelist,updateWhitelistEntry,isWhitelisted,
getKeyGroups,setKeyGroups,addKeyGroup,updateKeyGroup,removeKeyGroup,moveKeyGroup,
getFilters,setFilters,getStats,addStat,resetStats,
getHistory,addHistory,clearHistory,
getGlobal,setGlobal,isEnabled,setEnabled,
exportAll,importAll,exportStats,exportHistoryCSV,
getAllTags,updateBlocklistTags,updateWhitelistTags,
};
})();
// ============================================================
// NOTIFICATION
// ============================================================
var Notif = (function() {
var wrap = null;
var log = [];
function ensureWrap() {
if (wrap && document.body.contains(wrap)) return;
wrap = h('div', {id:'ytf-nw'});
document.body.appendChild(wrap);
}
function show(msg, type, dur) {
type = type || 'info';
dur = (dur === undefined) ? 3500 : dur;
// 寫入 Log(不受 showToast 影響)
var entry = {msg:msg, type:type, time:Date.now()};
log.unshift(entry);
if (log.length > 50) log.pop();
document.dispatchEvent(new CustomEvent('ytf:log', {detail:entry}));
// showToast 關閉時只寫 Log,不彈出 Toast
var gs = S.getGlobal();
if (gs.showToast === false) return;
ensureWrap();
var msgSpan = h('span', {cls:'ytf-tm'}, [msg]);
var closeBtn = h('button', {cls:'ytf-tx'}, ['✕']);
var toast = h('div', {cls:'ytf-toast ytf-t-'+type}, [msgSpan, closeBtn]);
closeBtn.addEventListener('click', function() { dismiss(toast); });
wrap.appendChild(toast);
setTimeout(function() { toast.classList.add('ytf-ti'); }, 10);
if (dur > 0) setTimeout(function() { dismiss(toast); }, dur);
}
function dismiss(t) {
t.classList.remove('ytf-ti');
setTimeout(function() { if (t.parentNode) t.parentNode.removeChild(t); }, 300);
}
// showForce:強制彈出 Toast(不受 showToast 開關影響,用於快捷操作通知)
function showForce(msg, type, dur) {
type = type || 'success';
dur = (dur === undefined) ? 3500 : dur;
var entry = {msg:msg, type:type, time:Date.now()};
log.unshift(entry);
if (log.length > 50) log.pop();
document.dispatchEvent(new CustomEvent('ytf:log', {detail:entry}));
ensureWrap();
var msgSpan = h('span', {cls:'ytf-tm'}, [msg]);
var closeBtn = h('button', {cls:'ytf-tx'}, ['✕']);
var toast = h('div', {cls:'ytf-toast ytf-t-'+type}, [msgSpan, closeBtn]);
closeBtn.addEventListener('click', function() { dismiss(toast); });
wrap.appendChild(toast);
setTimeout(function() { toast.classList.add('ytf-ti'); }, 10);
if (dur > 0) setTimeout(function() { dismiss(toast); }, dur);
}
function reasonText(r) {
if (!r) return t('reason.unknown');
if (r.indexOf('keyword:')===0) return t('reason.keyword')+r.slice(8)+t('reason.keyword.suffix');
if (r.indexOf('channel:')===0) return t('reason.channel');
if (r.indexOf('language:')===0) return t('reason.language')+r.slice(9)+t('reason.language.suffix');
if (r==='duration') return t('reason.duration');
if (r==='views') return t('reason.views');
if (r==='date') return t('reason.date');
if (r==='live') return t('reason.live');
if (r==='upcoming') return t('reason.upcoming');
if (r==='members_only') return t('reason.members_only');
return r;
}
function summary(items) {
if (!items||!items.length) return;
var rs={};
items.forEach(function(it){ var r=it.reason||'?'; rs[r]=(rs[r]||0)+1; });
var txt=Object.keys(rs).map(function(r){ return reasonText(r)+(rs[r]>1?' ×'+rs[r]:''); }).join(t('notif.separator'));
show(t('notif.hidden.prefix')+items.length+t('notif.hidden.suffix')+txt);
}
return {show,showForce,dismiss,reasonText,summary,getLog:function(){return log.slice();}};
})();
// ============================================================
// FILTER
// ============================================================
var Filter = (function() {
var PROC='data-ytf-p', HIDE='data-ytf-h';
// ── Regex 編譯快取(避免每次 new RegExp,設定更新時清除)──
// ── videoId → {id, name} 快取(補強 Shorts / compact 卡片的頻道識別)──
var _vcm = (function(){
var cache = {};
var dirty = false;
var flushTimer = null;
// 載入持久化快取
try{ var saved=GM_getValue('ytf_vcm',null); if(saved) cache=JSON.parse(saved)||{}; }catch(e){}
function flush(){
flushTimer=null; if(!dirty) return;
// 只保留最近 500 筆
var keys=Object.keys(cache);
if(keys.length>500){var trim={};keys.slice(-400).forEach(function(k){trim[k]=cache[k];});cache=trim;}
try{GM_setValue('ytf_vcm',JSON.stringify(cache));}catch(e){}
dirty=false;
}
return {
set:function(vId,chId,chName){
if(!vId) return;
cache[vId]={id:chId||'',name:chName||''};
dirty=true;
clearTimeout(flushTimer);
flushTimer=setTimeout(flush,2000);
},
get:function(vId){ return vId?cache[vId]:null; }
};
})();
function getInfo(card) {
var info={channelId:'',channelName:'',title:'',description:'',dur:null,isLive:false,isUpcoming:false,isMember:false,videoId:'',publishDate:null,viewCount:null,isCollection:false};
try {
var tSels=[
// 新版 camelCase class
'a.ytLockupMetadataViewModelTitle span[role="text"]',
'h3.ytLockupMetadataViewModelHeadingReset a span',
// 舊版 kebab-case class
'a.yt-lockup-metadata-view-model__title span[role="text"]',
'h3.yt-lockup-metadata-view-model__heading-reset a span',
// 更舊版 / 其他頁面
'yt-formatted-string#video-title',
'#video-title-link yt-formatted-string',
'#video-title',
'h3 a'
];
for (var i=0;i<tSels.length;i++){var te=card.querySelector(tSels[i]);if(te&&te.textContent.trim()){info.title=te.textContent.trim();break;}}
var chSels=['a.yt-core-attributed-string__link[href^="/@"]','a.yt-core-attributed-string__link[href^="/channel/"]','ytd-channel-name a','#channel-name a','a[href^="/@"]'];
for (var j=0;j<chSels.length;j++){
var ca=card.querySelector(chSels[j]); if(!ca) continue;
var name='';
ca.childNodes.forEach(function(n){
if(n.nodeType===3) name+=n.textContent;
else if(n.nodeType===1&&n.tagName==='SPAN'&&!n.querySelector('svg')){n.childNodes.forEach(function(nn){if(nn.nodeType===3)name+=nn.textContent;});}
});
if(!name.trim()) name=ca.textContent;
if(name.trim()){info.channelName=name.trim();info.channelId=(ca.getAttribute('href')||'').replace(/^\//,'').split('?')[0];break;}
}
// 新版 ViewModel:頻道名在 span 而非 <a>,從 avatar aria-label 取得
// 首頁:yt-spec-avatar-shape__button(kebab)
// 側邊欄 compact:ytSpecAvatarShapeButton(camelCase)
if(!info.channelName) {
var avatarBtn=card.querySelector(
'.yt-spec-avatar-shape__button[aria-label],' +
'.ytSpecAvatarShapeButton[aria-label]'
);
if(avatarBtn){
var al=avatarBtn.getAttribute('aria-label')||'';
// aria-label 格式:「前往頻道:頻道名稱」或「Go to channel: Name」
var m=al.match(/(?:前往頻道[::]\s*|Go to channel:\s*)(.+)/i);
if(m&&m[1]) info.channelName=m[1].trim();
}
}
// 再嘗試從 metadata span 取頻道名(第一個 metadata row)
if(!info.channelName) {
var metaRow=card.querySelector('.ytContentMetadataViewModelMetadataRow .ytContentMetadataViewModelMetadataText');
if(metaRow){
var txt=metaRow.textContent.trim();
// 排除「觀看次數」「X年前」等統計資訊
if(txt&&!/^[0-9,.]|次$|前$|前$/.test(txt)) info.channelName=txt;
}
}
// 時長:從縮圖底部的 badge 取得(格式 M:SS 或 H:MM:SS)
// 同時支援新版 camelCase (ytBadgeShapeText) 和舊版 kebab-case
var durBadges=card.querySelectorAll(
'.yt-badge-shape__text,badge-shape .yt-badge-shape__text,' +
'.ytBadgeShapeText,badge-shape .ytBadgeShapeText'
);
for(var b=0;b<durBadges.length;b++){
var bt=durBadges[b].textContent.trim();
if(/^\d{1,2}:\d{2}(:\d{2})?$/.test(bt)){info.dur=parseDur(bt);break;}
}
// aria-label fallback:從 <a> 標題的 aria-label 取時長(如:「...47 分鐘」)
if(info.dur===null){
var titleLink=card.querySelector('.ytLockupMetadataViewModelTitle[aria-label],.ytLockupViewModelTitle[aria-label]');
if(titleLink){
var al2=titleLink.getAttribute('aria-label')||'';
var mh=al2.match(/(\d+)\s*小時(?:[^分]*?(\d+)\s*分)?/);
var mm=al2.match(/(\d+)\s*分(?:[^秒]*?(\d+)\s*秒)?/);
var ms=al2.match(/(\d+)\s*秒/);
if(mh) info.dur=(parseInt(mh[1])*3600)+(mh[2]?parseInt(mh[2])*60:0);
else if(mm) info.dur=(parseInt(mm[1])*60)+(mm[2]?parseInt(mm[2]):0);
else if(ms) info.dur=parseInt(ms[1]);
}
}
// ── 直播/首播偵測(只看縮圖 overlay,不看頻道頭像)──
// 方式A:舊版 ytd-thumbnail-overlay-time-status-renderer 的 overlay-style 屬性
var ov=card.querySelector('ytd-thumbnail-overlay-time-status-renderer');
if(ov){
var os=ov.getAttribute('overlay-style')||'';
if(os==='LIVE') info.isLive=true;
if(os==='UPCOMING') info.isUpcoming=true;
}
// 方式B:新版 ViewModel 縮圖 badge(只看縮圖區塊內的 badge,排除頻道頭像)
// 縮圖區塊:a.ytLockupViewModelContentImage 或 yt-thumbnail-view-model
var thumbArea = card.querySelector(
'a.ytLockupViewModelContentImage, a.yt-lockup-view-model__content-image, yt-thumbnail-view-model, #thumbnail'
);
if(thumbArea) {
// 同時支援新版 camelCase(ytBadgeShapeText)和舊版 kebab-case(yt-badge-shape__text)
var thumbBadges = thumbArea.querySelectorAll(
'.yt-badge-shape__text,badge-shape .yt-badge-shape__text,' +
'.ytBadgeShapeText,badge-shape .ytBadgeShapeText'
);
for(var bx=0; bx<thumbBadges.length; bx++){
var bval = thumbBadges[bx].textContent.trim().toUpperCase();
// 只有純文字 badge 才是直播/首播,時長格式(數字:數字)已在 duration 讀取
if(!(/^\d/.test(bval))) {
if(bval==='LIVE'||bval==='直播') info.isLive=true;
if(bval==='UPCOMING'||bval==='即將首播'||bval==='首播'||bval==='PREMIERES') info.isUpcoming=true;
}
}
// 備援:直接用 class 識別直播 badge(ytBadgeShapeThumbnailLive)
if(!info.isLive && thumbArea.querySelector('.ytBadgeShapeThumbnailLive')) info.isLive=true;
}
// 舊版:badge-style 屬性(ytd-badge-supported-renderer)
if(card.querySelector('[badge-style="MEMBERS_ONLY"]')) info.isMember=true;
// 新版 ViewModel:yt-badge-shape--commerce class(metadata row 內,非縮圖)
if(!info.isMember && card.querySelector('.yt-badge-shape--commerce')) info.isMember=true;
// 雙重保障:badge 文字比對(含新版 camelCase)
if(!info.isMember){
var allBadgeTxt=card.querySelectorAll(
'.yt-badge-shape__text,.ytBadgeShapeText'
);
for(var bi2=0;bi2<allBadgeTxt.length;bi2++){
if(/會員|members.only/i.test(allBadgeTxt[bi2].textContent)){info.isMember=true;break;}
}
}
var de=card.querySelector('#description-text'); if(de) info.description=de.textContent.trim();
// ── 上傳日期:優先從觀看頁 meta tag 取精確日期;首頁從相對時間估算 ──
var dateMeta=document.querySelector('meta[itemprop="datePublished"]');
if(dateMeta){
var dv=dateMeta.getAttribute('content');
if(dv&&/^\d{4}-\d{2}-\d{2}/.test(dv)) info.publishDate=dv;
}
// 首頁卡片:從 metadata row 解析「1 天前 / 3 週前」等相對時間
// 一次迴圈解析 metadata row:上傳日期(相對時間)+ 觀看數
var allMetaTexts=card.querySelectorAll('.ytContentMetadataViewModelMetadataText');
for(var mi=0;mi<allMetaTexts.length;mi++){
var mt=allMetaTexts[mi].textContent.trim();
if(!mt) continue;
// 上傳日期:優先比對相對時間(含「前」或「ago」),若 publishDate 已從觀看頁 meta 取得則跳過
if(!info.publishDate){
// 排除「正在觀看」等直播文字
if(!/watching|正在觀看/i.test(mt)){
var relTs=parseRelativeTime(mt);
if(relTs!==null){ info.publishDate='__rel__'+relTs; }
}
}
// 觀看數:比對「觀看次數:N萬次」/「N.NM views」格式
if(info.viewCount===null){
// 中文:「觀看次數:2萬次」/「觀看次數:361萬次」
var mZh=mt.match(/觀看次數[::]\s*([\d.,]+)\s*([萬億万]?)\s*次/);
// 英文:「1.2M views」/「500 views」(排除「N watching」直播格式)
var mEn=!mZh && !/watching/i.test(mt) ? mt.match(/^([\d.,]+)\s*([KMBkmb])?\s*views?$/i) : null;
if(mZh) info.viewCount=parseViews(mZh[1], mZh[2]);
else if(mEn) info.viewCount=parseViews(mEn[1], mEn[2]);
}
}
// ── videoId 提取(從縮圖連結或標題連結)──
var vLink=card.querySelector('a[href*="watch?v="],a[href*="/shorts/"]');
if(vLink){
var vh=vLink.getAttribute('href')||'';
var vm=vh.match(/(?:watch\?v=|\/shorts\/)([a-zA-Z0-9_-]{11})/);
if(vm) info.videoId=vm[1];
// 合輯偵測:YouTube Mix(list=RD...)/ Radio 類卡片,非單一頻道影片
if(/[?&]list=(RD|OL)/.test(vh)) info.isCollection=true;
}
// 合輯偵測:badge 文字是「合輯」/「Mix」
if(!info.isCollection){
var cBadges=card.querySelectorAll('.ytBadgeShapeText,badge-shape .ytBadgeShapeText');
for(var cbi=0;cbi<cBadges.length;cbi++){
var cbt=cBadges[cbi].textContent.trim();
if(cbt==='合輯'||/^mix$/i.test(cbt)){ info.isCollection=true; break; }
}
}
// vcm 雙向同步:識別成功時存入快取;失敗時嘗試從快取補全
if(info.videoId){
if(info.channelName||info.channelId){
_vcm.set(info.videoId,info.channelId,info.channelName);
} else {
var cached=_vcm.get(info.videoId);
if(cached){ info.channelId=cached.id; info.channelName=cached.name; }
}
}
} catch(e){console.warn('[YTFilter] getInfo',e);}
return info;
}
function parseDur(t){var p=t.split(':').map(Number);if(p.some(isNaN))return null;return p.length===3?p[0]*3600+p[1]*60+p[2]:p[0]*60+p[1];}
// 解析 YouTube 相對時間文字 → 估算的毫秒時間戳(首頁卡片無精確日期)
// 中文:1 天前 / 3 週前 / 2 個月前 / 1 年前
// 英文:1 day ago / 3 weeks ago / 2 months ago / 1 year ago
function parseRelativeTime(txt) {
if(!txt) return null;
txt = txt.trim();
var now = Date.now();
if(/剛剛|just\s*now/i.test(txt)) return now;
var m = txt.match(/(\d+)\s*(天|day|days|週|week|weeks|個月|month|months|年|year|years|小時|hour|hours|分鐘|minute|minutes)/i);
if(!m) return null;
var n = parseInt(m[1]);
var u = m[2].toLowerCase();
var mult = {
'天':86400000,'day':86400000,'days':86400000,
'週':604800000,'week':604800000,'weeks':604800000,
'個月':2592000000,'month':2592000000,'months':2592000000,
'年':31536000000,'year':31536000000,'years':31536000000,
'小時':3600000,'hour':3600000,'hours':3600000,
'分鐘':60000,'minute':60000,'minutes':60000
};
var ms = mult[u];
return ms ? now - n * ms : null;
}
// 解析觀看數字串 → 整數(中文:萬 / 億;英文:K / M / B)
function parseViews(num, unit) {
var n = parseFloat(String(num).replace(/,/g, ''));
if (isNaN(n)) return null;
unit = (unit || '').trim();
if (unit === '萬' || unit === '万') n *= 10000;
else if (unit === '億') n *= 100000000;
else if (/^k$/i.test(unit)) n *= 1000;
else if (/^m$/i.test(unit)) n *= 1000000;
else if (/^b$/i.test(unit)) n *= 1000000000;
return Math.round(n);
}
function detectLang(t) {
// 依 Unicode block 偵測語言,回傳 BCP-47 代碼
if (/[-]/.test(t)) return 'th'; // 泰文 Thai
if (/[ऀ-ॿ]/.test(t)) return 'hi'; // 印地文 Hindi (Devanagari)
if (/[ঀ-]/.test(t)) return 'bn'; // 孟加拉文 Bengali
if (/[-ۿݐ-ݿ]/.test(t)) return 'ar'; // 阿拉伯文 Arabic
if (/[Ѐ-ӿ]/.test(t)) return 'ru'; // 俄文 (Cyrillic)
if (/[-ゟ゠-ヿ]/.test(t)) return 'ja'; // 日文 (Hiragana/Katakana)
if (/[가-]/.test(t)) return 'ko'; // 韓文 Korean
if (/[一-鿿㐀-䶿]/.test(t)) return 'zh'; // 中文 CJK
if (/[က-႟]/.test(t)) return 'my'; // 緬甸文 Myanmar/Burmese
if (/[-]/.test(t)) return 'si'; // 僧伽羅文 Sinhala
if (/[ఀ-౿]/.test(t)) return 'te'; // 泰盧固文 Telugu
if (/[-]/.test(t)) return 'ta'; // 泰米爾文 Tamil
if (/[-]/.test(t)) return 'pa'; // 旁遮普文 Punjabi
if (/[Ⴀ-ჿ]/.test(t)) return 'ka'; // 喬治亞文 Georgian
if (/[-֏]/.test(t)) return 'hy'; // 亞美尼亞文 Armenian
if (/[ሀ-]/.test(t)) return 'am'; // 阿姆哈拉文 Amharic (Ethiopic)
if (/[-]/.test(t)) return 'he'; // 希伯來文 Hebrew
// 拉丁字母類(根據常見特殊字元區分)
// 越南文優先偵測(有獨有聲調組合字元,且與其他拉丁語系字元集重疊最少)
if (/[ắặẩẫầảấẻẽẹếềểễệỉịọốồổỗộớờởỡợụứừửữựỳỵ]/i.test(t)) return 'vi'; // 越南文
if (/[äöüßÄÖÜ]/.test(t)) return 'de'; // 德文
if (/[àâçéèêëîïôùûüÿÀÂÇÉÈÊËÎÏÔÙÛÜŸœæ]/.test(t)) return 'fr'; // 法文
if (/[áéíóúüñ¿¡]/.test(t)) return 'es'; // 西班牙文
if (/[àèéìíîòóùúÀÈÉÌÍÎÒÓÙÚ]/.test(t)) return 'it'; // 義大利文
if (/[ãõáéíóúâêôç]/.test(t)) return 'pt'; // 葡萄牙文
if (/[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/.test(t)) return 'pl'; // 波蘭文
if (/[áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/.test(t)) return 'hu'; // 匈牙利文
if (/[蚞ȊŽ]/.test(t)) return 'cs'; // 捷克/斯洛伐克文
if (/[øæå]/.test(t)) return 'no'; // 北歐文(挪威/丹麥/瑞典)
// 純 ASCII 拉丁文 → 英文(預設)
return 'en';
}
function check(info) {
var fs=S.getFilters();
// ══ 第一優先:白名單保護 ══════════════════════════════════
// 白名單頻道不受任何過濾條件拘束(直播/會員/關鍵字等全部跳過)
if(fs.whitelistOnly) {
// 白名單獨享模式:只保留白名單頻道
if(S.isWhitelisted(info.channelId, info.channelName)) return null;
// 頻道資訊無法識別時保守顯示,避免誤殺正常卡片
if(!info.channelId && !info.channelName) return null;
return 'channel:'+(info.channelName||info.channelId);
}
// 一般白名單保護
if(S.isWhitelisted(info.channelId, info.channelName)) return null;
// ══ 第二層:頻道隱藏名單 ══════════════════════════════════
if(S.isBlocked(info.channelId, info.channelName))
return 'channel:'+(info.channelName||info.channelId);
// ══ 第三層:關鍵字過濾(標題/描述)══════════════════════
var groups=S.getKeyGroups().filter(function(g){return g.enabled;});
for(var gi=0;gi<groups.length;gi++){
var g=groups[gi];
for(var ki=0;ki<g.keywords.length;ki++){
var kw=g.keywords[ki];
var targets=[];
if(g.scope.indexOf('title')>=0&&info.title) targets.push(info.title);
if(g.scope.indexOf('description')>=0&&info.description) targets.push(info.description);
for(var ti=0;ti<targets.length;ti++){
var _kre=kw.isRegex?compiledRe(kw.value):null;
var hit=_kre?_kre.test(targets[ti]):targets[ti].toLowerCase().indexOf(kw.value.toLowerCase())>=0;
if(hit) return 'keyword:'+kw.value;
}
}
}
// ══ 第四層:影片屬性過濾 ══════════════════════════════════
// 直播 / 首播:根據影片縮圖 overlay 的屬性判斷,不根據頻道屬性
if(fs.live.enabled){
if(fs.live.hideLive && info.isLive) return 'live';
if(fs.live.hideUpcoming && info.isUpcoming) return 'upcoming';
}
// 會員專屬:影片有付費 badge 才觸發
if(fs.member.enabled && info.isMember) return 'members_only';
// 時長:影片時長不在範圍內
if(fs.duration.enabled && info.dur!==null){
var mn=fs.duration.min||0, mx=fs.duration.max||0;
if(info.dur<mn || (mx>0 && info.dur>mx)) return 'duration';
}
// 觀看數:超出 min / max 範圍(直播「N 人正在觀看」格式不會寫入 viewCount)
if(fs.views && fs.views.enabled && info.viewCount!==null){
var vmn=fs.views.min||0, vmx=fs.views.max||0;
if(vmn>0 && info.viewCount<vmn) return 'views';
if(vmx>0 && info.viewCount>vmx) return 'views';
}
// ══ 第五層:上傳日期過濾(觀看頁精確日期 或 首頁相對時間估算)══════
if(fs.uploadDate && fs.uploadDate.enabled && info.publishDate){
var _pubMs;
if(info.publishDate.indexOf('__rel__')===0){
// 首頁相對時間:直接使用估算時間戳
_pubMs=parseInt(info.publishDate.slice(7));
} else {
_pubMs=new Date(info.publishDate).getTime();
}
if(!isNaN(_pubMs)){
var _nowMs=Date.now();
var _limitMs=(fs.uploadDate.days||365)*86400000;
// v1.1.1 修正:語義清楚化
// 'older' = 隱藏比 N 天/年老的(舊片) → (now-pub) > limit 則 hide
// 'newer' = 隱藏比 N 天/年新的(新片) → (now-pub) < limit 則 hide
if(fs.uploadDate.condition==='older' && (_nowMs-_pubMs)>_limitMs) return 'date';
if(fs.uploadDate.condition==='newer' && (_nowMs-_pubMs)<_limitMs) return 'date';
}
}
// ══ 第六層:語言過濾 ══════════════════════════════════════
if(fs.language.enabled && info.title){
var lang=detectLang(info.title);
var allLangs=(fs.language.langs||[]).concat(fs.language.customLangs||[]);
if(allLangs.length){
var inList=allLangs.some(function(l){return lang&&(lang===l||lang.indexOf(l+'-')===0);});
if(fs.language.mode==='block' && inList) return 'language:'+lang;
if(fs.language.mode==='allow' && !inList) return 'language:'+lang;
}
}
return null;
}
function processOne(card) {
if(card.getAttribute(PROC)) return null;
if(!S.isEnabled()) return null;
ftCount('processOneCalls');
// v1.1.0: 起由 Observer 的 :has() SEL 從源頭避免父子重複選取,
// 不再需要 innerLockup 去重補丁
var info=getInfo(card);
// metadata 尚未載入(無標題且無頻道名),不打標記,等下次 poll 重新處理
if(!info.title && !info.channelName) {
ftCount('processOneSkipped_noInfo');
if(_dbgOn){
ftDbg('processOne skip (no info):', card.tagName,
'children=', card.children.length,
'hasTitle=', !!card.querySelector('h3,yt-formatted-string,.ytLockupMetadataViewModelTitle'),
'hasChannel=', !!card.querySelector('a[href^="/@"]'));
}
return null;
}
// 合輯(YouTube Mix / Radio):非單一頻道內容,不套用任何過濾規則,打標記避免重複處理
if(info.isCollection){
card.setAttribute(PROC,'1');
if(_dbgOn) ftDbg('SKIP collection:', (info.title||'').slice(0,40));
return null;
}
// 會員過濾啟用時:metadata row 存在但 commerce badge 尚未渲染,等候最多 3 個 poll
var fs0=S.getFilters();
if(fs0.member && fs0.member.enabled) {
var hasMetaRow = card.querySelector('.ytContentMetadataViewModelMetadataRowMetadataRowWrap');
var hasCommerce = card.querySelector('.yt-badge-shape--commerce');
if(hasMetaRow && !hasCommerce) {
card._ytfMemberWait = (card._ytfMemberWait||0) + 1;
if(card._ytfMemberWait <= 3) {
ftCount('processOneSkipped_memberWait');
return null;
}
}
}
card.setAttribute(PROC,'1');
var reason=check(info);
if(reason){
ftCount('processOneHidden');
_ytfStats.checkReasons[reason]=(_ytfStats.checkReasons[reason]||0)+1;
if(_dbgOn) ftDbg('HIDE', info.channelName||'?', '| reason=', reason, '| title=', (info.title||'').slice(0,40));
card.setAttribute(HIDE,'1');
// 隱藏目標:若 card 在 ytd-rich-item-renderer 裡(首頁格子),hide 外層避免留白
// 否則 hide card 本身
var hideTarget = card.closest('ytd-rich-item-renderer') || card;
hideTarget.setAttribute(HIDE,'1');
hideTarget.style.setProperty('display','none','important');
S.addStat(reason,info.channelId,info.channelName);
S.addHistory({title:info.title,channelId:info.channelId,channelName:info.channelName,reason:reason});
return{info,reason};
}
ftCount('processOnePass');
if(_dbgOn) ftDbg('PASS', info.channelName||'?', '| title=', (info.title||'').slice(0,40));
return null;
}
function processAll(cards){var hidden=[];Array.prototype.slice.call(cards).forEach(function(c){var r=processOne(c);if(r)hidden.push(r);});return hidden;}
function reset(){
_reCache.clear(); // 設定可能已更新,清除 Regex 快取
var procCount=document.querySelectorAll('['+PROC+']').length;
var qaCount=document.querySelectorAll('[data-ytf-qa]').length;
// 清除過濾標記與隱藏樣式(包含祖先 ytd-rich-item-renderer 可能被加上 HIDE)
document.querySelectorAll('['+PROC+'],['+HIDE+']').forEach(function(el){
el.removeAttribute(PROC);
el.removeAttribute(HIDE);
el.style.removeProperty('display');
});
// 清除 QA 注入的快捷按鈕標記和殘留元素
document.querySelectorAll('[data-ytf-qa]').forEach(function(el){
el.removeAttribute('data-ytf-qa');
el.style.removeProperty('position');
el.querySelectorAll('.ytf-qa-bar,.ytf-qa-icons').forEach(function(b){
if(b.parentNode) b.parentNode.style.removeProperty('position');
if(b.parentNode) b.parentNode.removeChild(b);
});
});
}
return {processAll,processOne,reset,getInfo};
})();
// ============================================================
// OBSERVER
// ============================================================
var Obs = (function() {
var obs=null,poll=null,deb=null,cb=null;
// SEL 由陣列組成,每個 selector 個別附加 :not() 才能正確篩選
// 首頁的 ytd-rich-item-renderer 與內層 yt-lockup-view-model 有父子關係,
// 用 :has() 精確選取:有 lockup 時只選內層,無 lockup 時選外層
var SEL_ARR = [
// 新版 ViewModel 卡片(首頁、觀看頁側邊欄 compact 等)
// 不加 ytd-rich-item-renderer:not(:has()) fallback,避免選到
// YouTube 還沒載入內容的空殼 placeholder 導致頁面缺損
'yt-lockup-view-model',
// 其他頁面(搜尋、頻道頁等舊版 DOM)
'ytd-video-renderer',
'ytd-compact-video-renderer',
'ytd-grid-video-renderer',
'ytd-rich-grid-media'
];
var SEL = SEL_ARR.join(',');
function cards(){return document.querySelectorAll(SEL);}
function unprocessed(){
// 個別附加 :not([data-ytf-p]),避免只套用到最後一個 selector
var sel = SEL_ARR.map(function(s){return s+':not([data-ytf-p])';}).join(',');
return document.querySelectorAll(sel);
}
function runCb(all){
if(!cb) return;
var els=Array.prototype.slice.call(all?cards():unprocessed());
if(_dbgOn){
ftCount('obsTotalRuns');
_ytfStats.obsCardsSelected += els.length;
// 統計各 tagName
els.forEach(function(el){
var tag=el.tagName;
_ytfStats.selectorSamples[tag]=(_ytfStats.selectorSamples[tag]||0)+1;
});
ftDbg('Obs.runCb all='+all+' selected='+els.length+' tags=',
els.slice(0,3).map(function(e){return e.tagName;}));
}
if(els.length) cb(els);
}
function start(callback){
cb=callback;
if(obs)obs.disconnect();
obs=new MutationObserver(function(muts){if(muts.some(function(m){return m.addedNodes.length>0;})){clearTimeout(deb);deb=setTimeout(function(){runCb(false);},400);}});
obs.observe(document.body,{childList:true,subtree:true});
setTimeout(function(){runCb(true);},1000);
setTimeout(function(){runCb(true);},2500);
clearInterval(poll);
poll=setInterval(function(){runCb(false);},2000);
}
function stop(){if(obs){obs.disconnect();obs=null;}clearTimeout(deb);clearInterval(poll);}
function watchNav(fn){
var last=location.href;
var debNav=null;
function trigger(){clearTimeout(debNav);debNav=setTimeout(fn,800);}
// yt-navigate-finish:YouTube SPA 任何導航完成時觸發(含 Logo 點擊回首頁)
window.addEventListener('yt-navigate-finish',function(){
setTimeout(function(){last=location.href;fn();},600);
});
// URL polling 保底:防止 yt-navigate-finish 不觸發的邊角情況
setInterval(function(){
if(location.href!==last){last=location.href;trigger();}
},600);
}
return {start,stop,restart:function(c){stop();start(c);},watchNav,cards};
})();
// ============================================================
// IO
// ============================================================
var IO = (function() {
// UTF-8 BOM bytes(EF BB BF),確保 Excel 在 Windows 系統開啟時正確識別編碼避免亂碼
var UTF8_BOM = new Uint8Array([0xEF, 0xBB, 0xBF]);
function dl(blob,name){var url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();URL.revokeObjectURL(url);}
function csv(rows,name){var c=rows.map(function(r){return r.map(function(x){return'"'+String(x).replace(/"/g,'""')+'"';}).join(',');}).join('\n');dl(new Blob([UTF8_BOM, c],{type:'text/csv;charset=utf-8;'}),name);}
function ds(){var d=new Date();return d.getFullYear()+pad2(d.getMonth()+1)+pad2(d.getDate());}
function pad2(n){return String(n).padStart(2,'0');}
function readFile(file,cb){var r=new FileReader();r.onload=function(e){cb(e.target.result);};r.readAsText(file,'UTF-8');}
function parseCSV(file){return new Promise(function(res){readFile(file,function(txt){res(txt.replace(/^\uFEFF/,'').split('\n').filter(function(l){return l.trim();}).map(function(l){return l.split(',').map(function(c){return c.trim().replace(/^"|"$/g,'').replace(/""/g,'"');});}));});});}
function exportJSON(){dl(new Blob([JSON.stringify(S.exportAll(),null,2)],{type:'application/json'}),'yt-filter-'+ds()+'.json');Notif.show(t('io.toast.exported.json'),'success');}
function exportBlocklistCSV(){var rows=[[t('csv.header.channelId'),t('csv.header.channelName'),t('csv.header.tags')]];S.getBlocklist().forEach(function(c){rows.push([c.id,c.name,(c.tags||[]).join(';')]);});csv(rows,'yt-blocklist-'+ds()+'.csv');Notif.show(t('io.toast.exported.bl'),'success');}
function exportWhitelistCSV(){var rows=[[t('csv.header.channelId'),t('csv.header.channelName'),t('csv.header.tags')]];S.getWhitelist().forEach(function(c){rows.push([c.id,c.name,(c.tags||[]).join(';')]);});csv(rows,'yt-whitelist-'+ds()+'.csv');Notif.show(t('io.toast.exported.wl'),'success');}
function exportKeywordsCSV(){var rows=[[t('csv.header.groupName'),t('csv.header.keyword'),t('csv.header.regex'),t('csv.header.scope'),t('csv.header.enabled')]];S.getKeyGroups().forEach(function(g){g.keywords.forEach(function(kw){rows.push([g.name,kw.value,kw.isRegex?'Y':'N',g.scope.join(';'),g.enabled?'Y':'N']);});});csv(rows,'yt-keywords-'+ds()+'.csv');Notif.show(t('io.toast.exported.kw'),'success');}
function importJSON(file,mode){return new Promise(function(res,rej){readFile(file,function(txt){try{
var d=JSON.parse(txt);
// 記錄匯入前數量
var before={bl:S.getBlocklist().length,wl:S.getWhitelist().length,kg:S.getKeyGroups().length};
S.importAll(d,mode);
Filter.reset();
var after={bl:S.getBlocklist().length,wl:S.getWhitelist().length,kg:S.getKeyGroups().length};
var added=(after.bl-before.bl)+(after.wl-before.wl)+(after.kg-before.kg);
var msg=mode==='overwrite'?t('io.toast.imported.overwrite'):t('io.toast.imported.prefix')+added+t('io.toast.imported.suffixEnd');
Notif.show(msg,'success');
res(d);
}catch(e){Notif.show(t('io.toast.importFailed')+e.message,'error');rej(e);}});});}
function importBlocklistCSV(file){return parseCSV(file).then(function(rows){var added=0;rows.slice(1).forEach(function(r){if(r[0]||r[1]){if(S.addToBlocklist({id:r[0],name:r[1],tags:r[2]?r[2].split(';'):[]}))added++;}});var total=rows.slice(1).filter(function(r){return r[0]||r[1];}).length;
var skipped=total-added;
Filter.reset();
Notif.show(t('io.toast.imported.blPrefix')+added+t('common.items')+(skipped?t('io.toast.imported.skip')+skipped+t('io.toast.imported.suffixDup'):''),'success');});}
function importKeywordsCSV(file,mode){return parseCSV(file).then(function(rows){var map={};rows.slice(1).forEach(function(r){if(!r[0]||!r[1])return;if(!map[r[0]])map[r[0]]={name:r[0],enabled:r[4]!=='N',keywords:[],scope:r[3]?r[3].split(';'):['title']};map[r[0]].keywords.push({value:r[1],isRegex:r[2]==='Y'});});if(mode==='overwrite')S.setKeyGroups([]);Object.values(map).forEach(function(g){S.addKeyGroup(g);});var addedKg=Object.keys(map).length; Filter.reset();Notif.show(t('io.toast.imported.kwPrefix')+addedKg+t('common.groupSuffix'),'success');});}
function autoBackup(){
// 自動備份:使用固定檔名 yt-filter-backup.json(同名會覆蓋)
var gs = S.getGlobal();
var filename = 'yt-filter-backup.json';
// 如果使用者有設定前綴,用前綴_日期
if (gs.backupPrefix && gs.backupPrefix.trim()) {
filename = gs.backupPrefix.trim() + '-' + ds() + '.json';
}
dl(new Blob([JSON.stringify(S.exportAll(),null,2)],{type:'application/json'}), filename);
var g = S.getGlobal(); g.lastBackup = Date.now(); S.setGlobal(g);
Notif.show(t('io.toast.autoBackupDone') + filename,'success');
}
// 分享過濾清單:產生分享連結
function generateShareLink(what) {
// what: 'blocklist'|'whitelist'|'keywords'|'all'
var payload = {v:'1',t:what,d:Date.now()};
if(what==='blocklist'||what==='all') payload.bl=S.getBlocklist().map(function(c){return{id:c.id,name:c.name,tags:c.tags};});
if(what==='whitelist'||what==='all') payload.wl=S.getWhitelist().map(function(c){return{id:c.id,name:c.name};});
if(what==='keywords'||what==='all') payload.kg=S.getKeyGroups();
try {
var encoded = btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
return 'https://www.youtube.com/#ytf-import=' + encoded;
} catch(e) { return null; }
}
// 從 URL hash 偵測並匯入
function checkImportFromHash() {
var hash = location.hash || '';
var m = hash.match(/#ytf-import=([A-Za-z0-9+/=]+)/);
if(!m) return;
try {
var payload = JSON.parse(decodeURIComponent(escape(atob(m[1]))));
if(payload.v!=='1') return;
// 清除 hash,避免重複匯入
history.replaceState(null,'',location.pathname+location.search);
// 顯示預覽並詢問匯入
var items=[];
if(payload.bl) items.push(t('io.label.blocklist')+payload.bl.length+t('common.channels'));
if(payload.wl) items.push(t('io.label.whitelist')+payload.wl.length+t('common.channels'));
if(payload.kg) items.push(t('io.label.groups')+payload.kg.length+t('common.countOnly'));
Notif.show(t('io.toast.shareReceived')+items.join('、')+t('io.toast.shareReceivedEnd'),'info',6000);
// 存到暫存供面板讀取
try { sessionStorage.setItem('ytf_pending_share', JSON.stringify(payload)); } catch(e){}
} catch(e) { /* 格式不合,靜默忽略 */ }
}
function exportStatsJSON(){
dl(new Blob([JSON.stringify(S.exportStats(),null,2)],{type:'application/json'}),
'ytf-stats-'+ds()+'.json');
Notif.show(t('io.toast.exported.stats'),'success');
}
// UTF-8 BOM:在 IO 模組最頂部已宣告,此處直接使用
function exportHistoryCSV(){
// D-5 修正:把 reason key 換成本地化描述文字,並加 UTF-8 BOM
var raw = S.exportHistoryCSV();
var lines = raw.split('\n');
var header = lines.shift();
var localized = [header].concat(lines.map(function(line){
// CSV 第 4 欄是 reason key(如 keyword:xxx, date, live 等),轉為本地化描述
var cols = line.split(',');
if(cols.length >= 4 && cols[3]){
cols[3] = Notif.reasonText(cols[3]);
}
return cols.join(',');
}));
var csvText = localized.join('\n');
// 用 Uint8Array 明確輸出 BOM + UTF-8 bytes,讓 Excel 正確識別編碼
dl(new Blob([UTF8_BOM, csvText], {type:'text/csv;charset=utf-8;'}),
'ytf-history-'+ds()+'.csv');
Notif.show(t('io.toast.exported.hist'),'success');
}
return {exportJSON,exportBlocklistCSV,exportWhitelistCSV,exportKeywordsCSV,
importJSON,importBlocklistCSV,importKeywordsCSV,autoBackup,
generateShareLink,checkImportFromHash,
exportStatsJSON,exportHistoryCSV};
})();
// ============================================================
// PANEL — 全部用 createElement,零 innerHTML
// ============================================================
var Panel = (function() {
var root=null, bodyEl=null, ctEl=null, navEl=null, lbEl=null;
var isOpen=false, curTab='blocklist';
function fmtTime(ts){if(!ts)return'';var d=new Date(ts);return(d.getMonth()+1)+'/'+d.getDate()+' '+pad2(d.getHours())+':'+pad2(d.getMinutes());}
function pad2(n){return String(n).padStart(2,'0');}
// ── Switch element ──
function makeSwitch(id, checked, small) {
var input = h('input',{type:'checkbox',id:id});
if (checked) input.checked = true;
var span = h('span',{cls:'ytf-sl'});
var label = h('label',{cls:'ytf-sw'+(small?' ytf-sw-sm':''),'for':id},[input,span]);
return {label,input};
}
// ── Build panel shell ──
function build(forceRebuild) {
// YouTube SPA reload 後 DOM 可能已被清除,需重置
if (root && !document.body.contains(root)) {
root = null; bodyEl = null; ctEl = null; navEl = null; lbEl = null;
}
// 強制重建(例如切換語言後):移除舊 DOM 並重置狀態
if (forceRebuild && root) {
if (root.parentNode) root.parentNode.removeChild(root);
root = null; bodyEl = null; ctEl = null; navEl = null; lbEl = null;
}
if (root) return;
// Global switch
var gswInput = h('input',{type:'checkbox',id:'ytf-gsw'});
gswInput.checked = S.isEnabled();
gswInput.addEventListener('change', function() {
S.setEnabled(gswInput.checked);
Filter.reset(); FAB.update();
Notif.show(gswInput.checked?t('filter.enabled'):t('filter.disabled'));
if (gswInput.checked) doFilter(Obs.cards());
});
var xBtn = h('button',{id:'ytf-x',title:t('panel.close')},['✕']);
xBtn.addEventListener('click', hide);
var hd = h('div',{id:'ytf-hd'},[
h('span',{id:'ytf-logo'},['🔍 ' + t('panel.title'), h('small',{id:'ytf-ver'},['v1.1.1-rc1'])]),
h('div',{style:'display:flex;align-items:center;gap:12px'},[
h('label',{cls:'ytf-sw',style:'margin:0'},[gswInput, h('span',{cls:'ytf-sl'})]),
xBtn
])
]);
lbEl = h('div',{id:'ytf-lb'});
navEl = h('nav',{id:'ytf-nav'});
ctEl = h('div',{id:'ytf-ct'});
bodyEl = h('div',{id:'ytf-bd'},[hd, lbEl, navEl, ctEl]);
var ov = h('div',{id:'ytf-ov'});
ov.addEventListener('click', hide);
root = h('div',{id:'ytf-panel'},[ov, bodyEl]);
document.body.appendChild(root);
document.addEventListener('ytf:log', function(ev) {
if (!isOpen) return;
var item = h('div',{cls:'ytf-l ytf-l-'+ev.detail.type},'['+fmtTime(ev.detail.time)+'] '+ev.detail.msg);
lbEl.insertBefore(item, lbEl.firstChild);
while (lbEl.children.length > 4) lbEl.removeChild(lbEl.lastChild);
});
buildNav();
renderTab(curTab);
}
function buildNav() {
var gs = S.getGlobal();
var tabs=[
{id:'blocklist',l:t('tab.blocklist')},
{id:'whitelist',l:t('tab.whitelist')},
{id:'keywords',l:t('tab.keywords')},
{id:'filters',l:t('tab.filters')},
];
if(gs.enableStats!==false){
tabs.push({id:'stats',l:t('tab.stats')});
tabs.push({id:'history',l:t('tab.history')});
}
tabs.push({id:'io',l:t('tab.io')});
tabs.push({id:'about',l:t('tab.about')});
setChildren(navEl, tabs.map(function(t){
return h('button',{cls:'ytf-tab','data-tab':t.id},[t.l]);
}));
// 用事件委派,避免重複綁定問題
navEl.addEventListener('click',function(ev){
var tabBtn=ev.target.closest('.ytf-tab');
if(tabBtn && tabBtn.getAttribute('data-tab')) {
renderTab(tabBtn.getAttribute('data-tab'));
}
});
}
function renderTab(id) {
curTab=id;
navEl.querySelectorAll('.ytf-tab').forEach(function(b){ b.classList.toggle('ytf-tab-on',b.getAttribute('data-tab')===id); });
setChildren(ctEl, [makeTabContent(id)]);
bindTabEvents(id);
}
function show() {
if(!root)build();
root.querySelector('#ytf-gsw').checked=S.isEnabled();
root.classList.add('ytf-open');
isOpen=true;
// 自動 focus 當前 Tab 的搜尋欄
setTimeout(function(){
var searchEl=root.querySelector('#ytf-bs,#ytf-ws');
if(searchEl) searchEl.focus();
},180);
}
function hide() { if(root)root.classList.remove('ytf-open'); isOpen=false; }
function toggle() { isOpen?hide():show(); }
// ── Helper: input ──
function inp(id, placeholder, small) {
return h('input',{cls:'ytf-in'+(small?' ytf-ins':''),type:'text',id:id||'',placeholder:placeholder||''});
}
function numInp(id, val, min2) {
var el=h('input',{cls:'ytf-in ytf-ins',type:'number',id:id,min:String(min2||0)});
el.value=String(val||0); return el;
}
function btn(cls, id, text) { return h('button',{cls:cls,id:id||''},[text]); }
function row() { return h('div',{cls:'ytf-row'},Array.prototype.slice.call(arguments)); }
// ── Channel list DOM ──
// tagFilter: 當前選取的標籤篩選(空字串=全部)
// onTagsChanged: callback 供上層 tag bar 收到「標籤有變動」時刷新
function makeChList(list, type, filter, tagFilter, onTagsChanged) {
filter = filter||''; tagFilter = tagFilter||'';
var fl = list;
if(filter) fl = fl.filter(function(c){
return (c.name||'').toLowerCase().indexOf(filter.toLowerCase())>=0 ||
(c.id||'').toLowerCase().indexOf(filter.toLowerCase())>=0;
});
if(tagFilter) fl = fl.filter(function(c){
return (c.tags||[]).indexOf(tagFilter) >= 0;
});
if(!fl.length) return h('div',{cls:'ytf-mt'},[list.length?t('channel.noResults'):t('channel.empty.blocklist')]);
// datalist 用於標籤建議(僅顯示同類型的標籤)
var allTagNames = S.getAllTags(type).map(function(t){return t.name;});
var dl = h('datalist',{id:'ytf-tags-dl'});
allTagNames.forEach(function(t){ dl.appendChild(h('option',{value:t},[])); });
var ul = h('ul',{cls:'ytf-cl'});
ul.appendChild(dl);
fl.forEach(function(c){
var key = c.id||c.name;
// 確認移除列
var yesBtn = h('button',{cls:'ytf-del-yes'},[t('channel.confirmRemove')]);
var noBtn = h('button',{cls:'ytf-del-no'},[t('common.cancel')]);
var confirmRow = h('div',{cls:'ytf-del-confirm ytf-hid'},[
h('span',{cls:'ytf-del-confirm-txt'},[t('channel.confirmRemovePrefix')+(c.name||c.id)+t('channel.confirmRemoveSuffix')]),
yesBtn, noBtn
]);
yesBtn.addEventListener('click',function(ev){
ev.stopPropagation();
if(type==='block') S.removeFromBlocklist(key);
else S.removeFromWhitelist(key);
Filter.reset(); renderTab(curTab);
});
noBtn.addEventListener('click',function(ev){
ev.stopPropagation();
confirmRow.classList.add('ytf-hid');
});
// ── 標籤區(v1.1.0: 隱藏名單與白名單都支援) ──
var tagsRow = null;
var tagInputRow = null;
// 根據 type 選擇對應的 Storage 操作函式
var getList = type==='block' ? S.getBlocklist : S.getWhitelist;
var updateTags = type==='block' ? S.updateBlocklistTags : S.updateWhitelistTags;
{
var tagChips = h('div',{cls:'ytf-tag-chips'});
function refreshTagChips(tags) {
while(tagChips.firstChild) tagChips.removeChild(tagChips.firstChild);
(tags||[]).forEach(function(tg){
var rmBtn = h('button',{cls:'ytf-tag-rm',title:t('channel.removeTag')},['×']);
rmBtn.addEventListener('click',function(ev){
ev.stopPropagation();
var cur = getList().find(function(x){return x.id===key||x.name===key;});
var newTags = (cur&&cur.tags||[]).filter(function(x){return x!==tg;});
updateTags(key, newTags);
// 局部更新:tag chips + 已有標籤快選列 + 上層 tag bar
refreshTagChips(newTags);
if(typeof refreshExistingChips === 'function') refreshExistingChips();
var rmIdx=list.findIndex(function(x){return x.id===key||x.name===key;});
if(rmIdx>=0) list[rmIdx]=Object.assign({},list[rmIdx],{tags:newTags});
if(typeof onTagsChanged === 'function') onTagsChanged();
});
var chip = h('span',{cls:'ytf-tag ytf-tag-edit'},[tg, rmBtn]);
tagChips.appendChild(chip);
});
}
refreshTagChips(c.tags||[]);
// v1.1.0: 修訂:標籤 UI 重新設計,邏輯分區清楚
// ┌──────────────────────────────────────┐
// │ 📝 新增標籤 │
// │ [輸入新標籤.........] [+ 新增] │
// │ ─────────────────────────────────── │
// │ 📌 已有標籤(快選,點即加入) │
// │ [tag1(3)] [tag2(1)] [tag3(5)] ... │
// └──────────────────────────────────────┘
var tagIn = h('input',{type:'text',cls:'ytf-in',placeholder:t('channel.newTagPh')});
var addTagBtn = h('button',{cls:'ytf-tag-add-btn',title:t('common.add')},['+ '+t('common.add')]);
var existingTagsWrap = h('div',{cls:'ytf-tag-existing-chips'});
// 刷新「已有標籤」chip 列
// v1.1.0: 修訂:顯示所有系統標籤,已擁有的以「已選」狀態呈現(點擊移除)
// 讓使用者一眼看出所有可用標籤以及該頻道的擁有狀態
function refreshExistingChips() {
while(existingTagsWrap.firstChild) existingTagsWrap.removeChild(existingTagsWrap.firstChild);
var cur = getList().find(function(x){return x.id===key||x.name===key;});
var have = (cur&&cur.tags)||[];
var all = S.getAllTags(type);
if(!all.length){
existingTagsWrap.appendChild(h('span',{cls:'ytf-tag-pick-empty'},[t('channel.noExistingTags')]));
return;
}
all.forEach(function(tg){
var owned = have.indexOf(tg.name) >= 0;
var item = h('button',{cls:'ytf-tag-pick-chip'+(owned?' ytf-tag-pick-chip-on':'')},
[tg.name, h('small',{},[' ('+tg.count+')'])]);
item.addEventListener('click',function(ev){
ev.stopPropagation();
if(owned){
// 已擁有 → 點擊移除
var newTags = have.filter(function(x){return x!==tg.name;});
updateTags(key, newTags);
refreshTagChips(newTags);
refreshExistingChips();
var idx=list.findIndex(function(x){return x.id===key||x.name===key;});
if(idx>=0) list[idx]=Object.assign({},list[idx],{tags:newTags});
if(typeof onTagsChanged === 'function') onTagsChanged();
} else {
// 未擁有 → 點擊加入
tagIn.value = tg.name;
doAddTag();
}
});
existingTagsWrap.appendChild(item);
});
}
function doAddTag() {
var val = tagIn.value.trim();
if(!val) return;
var cur = getList().find(function(x){return x.id===key||x.name===key;});
var curTags = cur&&cur.tags ? cur.tags.slice() : [];
if(curTags.indexOf(val)<0){
curTags.push(val);
updateTags(key,curTags);
// 清空輸入框但保持面板打開,讓使用者可連續新增多個 tag
tagIn.value='';
// 局部更新:已有 chip 列 + 現有標籤快選 + 上層 tag bar
refreshTagChips(curTags);
refreshExistingChips();
// 同步 list 快照,再通知上層 tag bar 刷新
var idx=list.findIndex(function(x){return x.id===key||x.name===key;});
if(idx>=0) list[idx]=Object.assign({},list[idx],{tags:curTags});
if(typeof onTagsChanged === 'function') onTagsChanged();
tagIn.focus();
} else {
// 重複則清空輸入,保持打開
tagIn.value='';
tagIn.focus();
}
}
tagIn.addEventListener('keydown',function(ev){ if(ev.key==='Enter'){ev.preventDefault();doAddTag();} });
addTagBtn.addEventListener('click',function(ev){ ev.stopPropagation(); doAddTag(); });
var addTagToggle = h('button',{cls:'ytf-tag-toggle',title:t('channel.manageTags')},['🏷']);
// 標籤新增面板(分區:新增輸入 + 現有 chip 快選)
var addSection = h('div',{cls:'ytf-tag-section'},[
h('div',{cls:'ytf-tag-section-label'},['📝 '+t('channel.addTagLabel')]),
h('div',{cls:'ytf-tag-input-flex'},[tagIn, addTagBtn])
]);
var pickSection = h('div',{cls:'ytf-tag-section'},[
h('div',{cls:'ytf-tag-section-label'},['📌 '+t('channel.existingTagsLabel')]),
existingTagsWrap
]);
tagInputRow = h('div',{cls:'ytf-tag-input-row ytf-hid'},[addSection, pickSection]);
addTagToggle.addEventListener('click',function(ev){
ev.stopPropagation();
var willOpen = tagInputRow.classList.contains('ytf-hid');
tagInputRow.classList.toggle('ytf-hid');
if(willOpen){
refreshExistingChips(); // 每次打開都刷新可選項
tagIn.focus();
}
});
tagsRow = h('div',{cls:'ytf-tags-area'},[tagChips, addTagToggle]);
}
// 移除按鈕
var delBtn = h('button',{cls:'ytf-del',title:t('common.remove')},['✕']);
delBtn.addEventListener('click',function(ev){
ev.stopPropagation();
ul.querySelectorAll('.ytf-del-confirm:not(.ytf-hid)').forEach(function(r){
if(r!==confirmRow) r.classList.add('ytf-hid');
});
confirmRow.classList.toggle('ytf-hid');
});
var nameInfo = [];
if(c.isRegex) nameInfo.push(h('span',{cls:'ytf-re-badge'},['⌥R.*']));
nameInfo.push(h('b',{cls:'ytf-cn'},[c.name||c.id]));
if(c.id&&c.name&&!c.isRegex) nameInfo.push(h('small',{cls:'ytf-sid'},[c.id]));
// ── 編輯功能 ──
var editBtn = h('button',{cls:'ytf-edit-btn',title:t('common.edit')},['✏️']);
var editRow = h('div',{cls:'ytf-edit-row ytf-hid'});
// v1.1.0: 修正:移除 IIFE 讓 editBtn handler 可存取 eTagsIn 等編輯元件
var eNameIn = h('input',{type:'text',cls:'ytf-in',placeholder:t('channel.name')});
var eIdIn = h('input',{type:'text',cls:'ytf-in',placeholder:t('channel.handleShort')});
var eReChk = h('input',{type:'checkbox',id:'ytf-er-'+key});
eNameIn.value = c.name||'';
eIdIn.value = c.id||'';
eReChk.checked = !!c.isRegex;
var eSaveBtn = h('button',{cls:'ytf-bp',style:'font-size:12px;padding:5px 10px'},[t('common.save')]);
var eCancelBtn = h('button',{cls:'ytf-bg',style:'font-size:12px;padding:5px 10px'},[t('common.cancel')]);
eSaveBtn.addEventListener('click',function(ev){
ev.stopPropagation();
var newName = eNameIn.value.trim();
var newId = eIdIn.value.trim();
if(!newName&&!newId){ Notif.show(t('toast.channelEmpty'),'warning'); return; }
// v1.1.0: 同時儲存 tags
var newTags = (eTagsIn.value||'').split(',').map(function(x){return x.trim();}).filter(Boolean);
var upd = {name:newName, id:newId, isRegex:eReChk.checked, tags:newTags};
if(type==='block') S.updateBlocklistEntry(key, upd);
else S.updateWhitelistEntry(key, upd);
Filter.reset();
// v1.1.0: 修正:編輯儲存後需重建整個 Tab 以同步標籤篩選 bar
// (refreshList 只重繪 listWrap,不會刷新上方的 tag bar)
renderTab(type==='block'?'blocklist':'whitelist');
Notif.show(t('toast.updated')+newName,'success');
});
eCancelBtn.addEventListener('click',function(ev){
ev.stopPropagation();
editRow.classList.add('ytf-hid');
});
// v1.1.0: 白名單也支援標籤編輯
var eTagsIn = h('input',{type:'text',cls:'ytf-in',placeholder:t('channel.tagsHint'),list:'ytf-tags-dl'});
eTagsIn.value = (c.tags||[]).join(', ');
// v1.1.0: 任務 2: 編輯表單也提供已有標籤快選
var eTagPickWrap = h('div',{cls:'ytf-tag-existing-chips'});
function refreshEditTagPick(){
while(eTagPickWrap.firstChild) eTagPickWrap.removeChild(eTagPickWrap.firstChild);
// 當前輸入框中的 tags(逗號分隔、去除空白)
var currentTags = (eTagsIn.value||'').split(',').map(function(x){return x.trim();}).filter(Boolean);
var all = S.getAllTags(type);
if(!all.length){
eTagPickWrap.appendChild(h('span',{cls:'ytf-tag-pick-empty'},[t('channel.noExistingTags')]));
return;
}
all.forEach(function(tg){
var owned = currentTags.indexOf(tg.name) >= 0;
var item = h('button',{cls:'ytf-tag-pick-chip'+(owned?' ytf-tag-pick-chip-on':'')},
[tg.name, h('small',{},[' ('+tg.count+')'])]);
item.addEventListener('click',function(ev){
ev.preventDefault();
ev.stopPropagation();
var cur = currentTags.slice();
if(owned){
// 已擁有 → 移除
cur = cur.filter(function(x){return x!==tg.name;});
} else {
// 未擁有 → 加入
cur.push(tg.name);
}
eTagsIn.value = cur.join(', ');
refreshEditTagPick();
});
eTagPickWrap.appendChild(item);
});
}
// 輸入框內容變動時也刷新候選(使用者手打/刪除)
eTagsIn.addEventListener('input', refreshEditTagPick);
refreshEditTagPick();
var editFields = [
h('div',{cls:'ytf-edit-fields'},[
eNameIn, eIdIn,
h('label',{cls:'ytf-cb',style:'font-size:12px'},[eReChk,t('channel.editRegexMode')])
])
];
editFields[0].appendChild(eTagsIn);
// 已有標籤快選區(放在 tag 輸入框下方)
editFields[0].appendChild(h('div',{cls:'ytf-tag-section',style:'margin-top:4px'},[
h('div',{cls:'ytf-tag-section-label'},['📌 '+t('channel.existingTagsLabel')]),
eTagPickWrap
]));
editFields[0].appendChild(h('div',{cls:'ytf-row'},[eSaveBtn,eCancelBtn]));
setChildren(editRow, editFields);
editBtn.addEventListener('click',function(ev){
ev.stopPropagation();
// 關閉其他開啟的編輯列
ul.querySelectorAll('.ytf-edit-row:not(.ytf-hid)').forEach(function(r){
if(r!==editRow) r.classList.add('ytf-hid');
});
// v1.1.0: 修正:打開編輯表單前,重新從 Storage 讀取最新的 tags
// (使用者可能透過 🏷 區已經改過 tags,而 c 是 IIFE 當時的快照)
var latest = getList().find(function(x){return x.id===key||x.name===key;});
if(latest){
eNameIn.value = latest.name || '';
eIdIn.value = latest.id || '';
eReChk.checked = !!latest.isRegex;
eTagsIn.value = (latest.tags||[]).join(', ');
refreshEditTagPick();
}
editRow.classList.toggle('ytf-hid');
});
var actionBtns = h('div',{cls:'ytf-ci-actions'},[editBtn, delBtn]);
// D-4: 把 channelName 和 tagChips 放在同一個 flex-wrap 容器
// 原本 nameInfo 獨佔 flex:1、tagsRow 另起 flex:1,導致標籤被擠到名稱右邊的狹小空間
// 新做法:name + sid + chips 全部進 .ytf-ci-nametags(flex-wrap),空間不足時自動換行
var nameAndTags = h('div',{cls:'ytf-ci-nametags'},[]);
nameInfo.forEach(function(n){ nameAndTags.appendChild(n); });
if(tagsRow) nameAndTags.appendChild(tagsRow);
var liChildren = [
h('div',{cls:'ytf-ci-main'},[nameAndTags, actionBtns]),
editRow,
confirmRow
];
if(tagInputRow) liChildren.push(tagInputRow);
ul.appendChild(h('li',{cls:'ytf-ci ytf-ci-wrap'},liChildren));
});
return ul;
}
// ── Tabs content ──
function makeTabContent(id) {
switch(id) {
case 'blocklist': return makeBlocklist();
case 'whitelist': return makeWhitelist();
case 'keywords': return makeKeywords();
case 'filters': return makeFilters();
case 'stats': return makeStats();
case 'history': return makeHistory();
case 'io': return makeIO();
case 'about': return makeAbout();
}
return h('div');
}
// ── Blocklist/Whitelist 共用:標籤篩選 bar(v1.1.0: 修正:白名單也支援) ──
// 回傳 { render(): 重繪清單與 bar, getFilter(): 取得目前 filter, pane: DOM 框架 }
function buildTagBarWrapper(type, list, searchIn, listContainerId) {
var allTags = S.getAllTags(type);
var curTagFilter = '';
var tagBar = h('div',{cls:'ytf-tag-bar'});
// 當條目的 tags 變動時,重新計算 allTags 並刷新 bar
// v1.1.0: 任務 3 修正:若當前篩選的 tag 已不存在(被刪除/條目移除),自動回到「全部」並重繪清單
function onTagsChanged() {
allTags = S.getAllTags(type);
var stillExists = allTags.some(function(tg){ return tg.name===curTagFilter; });
if(curTagFilter && !stillExists){
curTagFilter = '';
setChildren(listWrap,[makeChList(list, type, searchIn?searchIn.value:'', curTagFilter, onTagsChanged)]);
}
renderBar();
}
var listWrap = h('div',{id:listContainerId},[makeChList(list, type, '', curTagFilter, onTagsChanged)]);
function renderBar() {
while(tagBar.firstChild) tagBar.removeChild(tagBar.firstChild);
if(!allTags.length) { tagBar.style.display='none'; return; }
tagBar.style.display='';
var allChip = h('button',{cls:'ytf-tagf'+(curTagFilter===''?' ytf-tagf-on':'')},
[t('channel.tagFilter.all')+list.length+t('channel.tagFilter.allSuffix')]);
allChip.addEventListener('click',function(){
curTagFilter='';
renderBar();
setChildren(listWrap,[makeChList(list, type, searchIn?searchIn.value:'', curTagFilter, onTagsChanged)]);
});
tagBar.appendChild(allChip);
allTags.forEach(function(tg){
var chip = h('button',{cls:'ytf-tagf'+(curTagFilter===tg.name?' ytf-tagf-on':'')},
[tg.name+' ('+tg.count+')']);
chip.addEventListener('click',function(){
curTagFilter = curTagFilter===tg.name ? '' : tg.name;
renderBar();
setChildren(listWrap,[makeChList(list, type, searchIn?searchIn.value:'', curTagFilter, onTagsChanged)]);
});
tagBar.appendChild(chip);
});
}
return {
tagBar: tagBar,
listWrap: listWrap,
render: renderBar,
refreshTags: function(){ allTags = S.getAllTags(type); renderBar(); },
getFilter: function(){ return curTagFilter; }
};
}
// ── Blocklist ──
function makeBlocklist() {
var list=S.getBlocklist();
var searchIn=inp('ytf-bs',t('channel.searchPlaceholder'));
var addBtn=btn('ytf-bp','ytf-ba',t('common.add'));
var clearBtn=btn('ytf-bg','ytf-blk-clr',t('channel.clearAll'));
var nameIn=inp('ytf-bn',t('channel.name'));
var idIn=inp('ytf-bi',t('channel.handle'));
var tagsIn=inp('ytf-bt',t('channel.tagsHint'));
var blReChk=h('input',{type:'checkbox',id:'ytf-bl-re'});
var okBtn=btn('ytf-bp','ytf-bok',t('common.confirm'));
var cancelBtn=btn('ytf-bg','',t('common.cancel'));
var form=h('div',{cls:'ytf-form ytf-hid',id:'ytf-bf'},[
nameIn,idIn,tagsIn,
h('label',{cls:'ytf-cb',style:'font-size:12px'},[blReChk,t('channel.regexMode')]),
h('small',{cls:'ytf-sid',style:'padding-left:22px'},[t('channel.regexHint')]),
row(okBtn,cancelBtn)
]);
var tbw = buildTagBarWrapper('block', list, searchIn, 'ytf-blist');
tbw.render();
var countEl = h('small',{cls:'ytf-sid',id:'ytf-bl-count'},[t('common.total')+list.length+t('common.channels')]);
return h('div',{cls:'ytf-pane'},[
row(searchIn,addBtn,list.length?clearBtn:null),
form,
tbw.tagBar,
tbw.listWrap,
countEl
]);
}
function bindTabEvents(id) {
if (!ctEl) return;
var qs=function(s){return ctEl.querySelector(s);};
if (id==='blocklist') {
var addBtn=qs('#ytf-ba'),form=qs('#ytf-bf');
if(addBtn&&form) addBtn.addEventListener('click',function(){form.classList.toggle('ytf-hid');});
var cancelBtn=form?form.querySelectorAll('button')[1]:null;
if(cancelBtn) cancelBtn.addEventListener('click',function(){form.classList.add('ytf-hid');});
var okBtn=qs('#ytf-bok');
if(okBtn) okBtn.addEventListener('click',function(){
var name=(qs('#ytf-bn').value||'').trim(),id2=(qs('#ytf-bi').value||'').trim();
var tags=(qs('#ytf-bt').value||'').split(',').map(function(t){return t.trim();}).filter(Boolean);
if(!name&&!id2){Notif.show(t('channel.addPrompt'),'warning');return;}
var isRe=!!(qs('#ytf-bl-re')&&qs('#ytf-bl-re').checked);
S.addToBlocklist({id:isRe?'':id2||name,name:name||id2,isRegex:isRe,tags});
Filter.reset();Notif.show(t('toast.added')+(name||id2),'success');renderTab('blocklist');
});
var searchIn=qs('#ytf-bs');
if(searchIn) searchIn.addEventListener('input',function(){
var lw=qs('#ytf-blist');
if(lw){
var tagOn=ctEl.querySelector('.ytf-tagf-on');
var tf=(tagOn&&tagOn.textContent.indexOf(t('common.all'))===0)?'':
(tagOn?tagOn.textContent.replace(/\s*\(\d+\)$/,''):'');
setChildren(lw,[makeChList(S.getBlocklist(),'block',searchIn.value,tf)]);
}
});
var clrBtn=qs('#ytf-blk-clr');
if(clrBtn) clrBtn.addEventListener('click',function(){
if(!confirm(t('channel.clearBlockConfirm')+S.getBlocklist().length+t('common.countSuffix'))) return;
S.setBlocklist([]); Filter.reset();
Notif.show(t('channel.cleared.block'),'success'); renderTab('blocklist');
});
}
if (id==='whitelist') {
var wa=qs('#ytf-wa'),wf=qs('#ytf-wf');
if(wa&&wf) wa.addEventListener('click',function(){wf.classList.toggle('ytf-hid');});
var wcancels=wf?wf.querySelectorAll('button'):[];
if(wcancels[1]) wcancels[1].addEventListener('click',function(){wf.classList.add('ytf-hid');});
var wok=qs('#ytf-wok');
if(wok) wok.addEventListener('click',function(){
var name=(qs('#ytf-wn').value||'').trim(),id2=(qs('#ytf-wi').value||'').trim();
if(!name&&!id2){Notif.show(t('channel.addPrompt'),'warning');return;}
var isReW=!!(qs('#ytf-wl-re')&&qs('#ytf-wl-re').checked);
// v1.1.0: 讀取白名單 tags 輸入
var wtagsEl=qs('#ytf-wt');
var tags=wtagsEl ? (wtagsEl.value||'').split(',').map(function(x){return x.trim();}).filter(Boolean) : [];
S.addToWhitelist({id:isReW?'':id2||name,name:name||id2,isRegex:isReW,tags:tags});
Filter.reset();Notif.show(t('qa.already.white')+(name||id2),'success');renderTab('whitelist');
});
var ws=qs('#ytf-ws');
// v1.1.0: 為了跟標籤篩選 bar 的 filter 保持一致,搜尋時重建整個 Tab
// 但要保留輸入框的值與游標位置(避免打字時失焦)
if(ws) ws.addEventListener('input',function(){
var lw=qs('#ytf-wlist');
if(lw) setChildren(lw,[makeChList(S.getWhitelist(),'white',ws.value)]);
});
var wclrBtn=qs('#ytf-wh-clr');
if(wclrBtn) wclrBtn.addEventListener('click',function(){
if(!confirm(t('channel.clearWhiteConfirm')+S.getWhitelist().length+t('common.countSuffix'))) return;
S.setWhitelist([]); Filter.reset();
Notif.show(t('channel.cleared.white'),'success'); renderTab('whitelist');
});
}
if (id==='keywords') {
var ga=qs('#ytf-ga'),gf=qs('#ytf-gf');
if(ga&&gf) ga.addEventListener('click',function(){gf.classList.toggle('ytf-hid');});
var gcancels=gf?gf.querySelectorAll('button'):[];
if(gcancels[1]) gcancels[1].addEventListener('click',function(){gf.classList.add('ytf-hid');});
var gok=qs('#ytf-gok');
if(gok) gok.addEventListener('click',function(){
var name=(qs('#ytf-gn').value||'').trim();if(!name){Notif.show(t('kw.groupName'),'warning');return;}
var scope=[]; var sct=qs('#ytf-sct'),scd=qs('#ytf-scd');
if(sct&&sct.checked)scope.push('title');if(scd&&scd.checked)scope.push('description');
S.addKeyGroup({name,scope:scope.length?scope:['title']});renderTab('keywords');
});
// 使用事件委派,避免多次渲染造成的重複綁定問題
var glistEl = ctEl.querySelector('#ytf-glist');
if (glistEl) {
glistEl.addEventListener('change', function(ev) {
var tog = ev.target.closest('.ytf-gt');
if (tog) {
S.updateKeyGroup(tog.getAttribute('data-id'), {enabled: tog.checked});
Filter.reset();
}
});
glistEl.addEventListener('click', function(ev) {
// 刪除群組
var gdBtn = ev.target.closest('.ytf-gd');
if (gdBtn) {
var gid = gdBtn.getAttribute('data-id');
var gs2 = S.getKeyGroups();
var grp = gs2.find(function(x){return x.id===gid;});
var gname = grp ? grp.name : gid;
if (!confirm(t('kw.confirmDelPrefix')+gname+t('kw.confirmDelSuffix2'))) return;
S.removeKeyGroup(gid); Filter.reset(); renderTab('keywords');
return;
}
// 展開關鍵字輸入列
var kaBtn = ev.target.closest('.ytf-ka');
if (kaBtn) {
var r = ctEl.querySelector('#ytf-ki-'+kaBtn.getAttribute('data-gid'));
if (r) r.classList.toggle('ytf-hid');
return;
}
// 新增關鍵字
var koBtn = ev.target.closest('.ytf-ko');
if (koBtn) {
var row2 = ctEl.querySelector('#ytf-ki-'+koBtn.getAttribute('data-gid'));
if (!row2) return;
var val = row2.querySelector('.ytf-kv').value.trim();
var isRe = row2.querySelector('.ytf-rc').checked;
if (!val) { Notif.show(t('kw.addKwPrompt'),'warning'); return; }
var gs3=S.getKeyGroups(), g3=gs3.find(function(x){return x.id===koBtn.getAttribute('data-gid');});
if (g3) { g3.keywords.push({value:val,isRegex:isRe}); S.setKeyGroups(gs3); Filter.reset(); renderTab('keywords'); }
return;
}
// 移除關鍵字
var kxBtn = ev.target.closest('.ytf-kx');
if (kxBtn) {
var gs4=S.getKeyGroups(), g4=gs4.find(function(x){return x.id===kxBtn.getAttribute('data-gid');});
if (g4) { g4.keywords.splice(Number(kxBtn.getAttribute('data-i')),1); S.setKeyGroups(gs4); Filter.reset(); renderTab('keywords'); }
return;
}
});
}
}
if (id==='filters') {
[['#ytf-dur','#ytf-dur-bd'],['#ytf-live','#ytf-live-bd'],['#ytf-views','#ytf-views-bd'],['#ytf-ud','#ytf-ud-bd'],['#ytf-lang','#ytf-lang-bd']].forEach(function(p){
var chk=qs(p[0]),bd=qs(p[1]);
if(chk&&bd) chk.addEventListener('change',function(){bd.classList.toggle('ytf-dis',!chk.checked);});
});
var saveBtn=qs('#ytf-fsave');
if(saveBtn) saveBtn.addEventListener('click',function(){
var langs=Array.prototype.slice.call(ctEl.querySelectorAll('.ytf-lk:checked')).map(function(el){return el.value;});
var lm=qs('#ytf-lm');
var customLangsRaw = (qs('#ytf-lcustom')||{}).value||'';
var customLangsParsed = customLangsRaw.split(',').map(function(s){return s.trim().toLowerCase();}).filter(function(s){return s.length>0;});
var udCondEl=qs('#ytf-ud-cond');
var udAmountEl=qs('#ytf-ud-amount');
var udUnitEl=qs('#ytf-ud-unit');
var udAmount=Number((udAmountEl||{}).value)||1;
var udUnit=(udUnitEl&&udUnitEl.value)||'day';
var udDays = udUnit==='year' ? udAmount*365 : udAmount;
S.setFilters({
whitelistOnly:(qs('#ytf-wlo')||{}).checked||false,
duration:{enabled:qs('#ytf-dur').checked,min:Number(qs('#ytf-dmin').value)||0,max:Number(qs('#ytf-dmax').value)||0},
live:{enabled:qs('#ytf-live').checked,hideLive:qs('#ytf-ll').checked,hideUpcoming:qs('#ytf-lu').checked},
member:{enabled:qs('#ytf-mem').checked},
uploadDate:{enabled:(qs('#ytf-ud')||{}).checked||false,condition:udCondEl?udCondEl.value:'older',amount:udAmount,unit:udUnit,days:udDays},
views:{enabled:(qs('#ytf-views')||{}).checked||false,min:Number((qs('#ytf-vmin')||{}).value)||0,max:Number((qs('#ytf-vmax')||{}).value)||0},
language:{enabled:qs('#ytf-lang').checked,mode:lm?lm.value:'block',langs:langs,customLangs:customLangsParsed},
});
// 語言切換(即時生效:重建面板、重建頁面上所有快捷按鈕)
var langSelEl=qs('#ytf-lang-sel');
if(langSelEl && langSelEl.value !== LANG){
setLang(langSelEl.value);
Panel.build(true);
Panel.show();
// 重跑過濾:Filter.reset() 清除所有 PROC/HIDE/QA 標記與殘留 bar,
// 然後重新 doFilter() 讓所有卡片用新語言重建快捷按鈕與 notif
Filter.reset();
doFilter(Obs.cards());
FAB.update(); // FAB 的 title/tip 也要用新語言
Notif.show(t('set.lang.switched'),'success');
return;
}
// 全域設定開關
var toastEl=qs('#ytf-toast'), statsEl=qs('#ytf-estats');
if(toastEl||statsEl){
var g2=S.getGlobal();
if(toastEl) g2.showToast=toastEl.checked;
if(statsEl){
var wasStats=g2.enableStats!==false;
g2.enableStats=statsEl.checked;
if(wasStats!==statsEl.checked){S.setGlobal(g2);buildNav();if(!statsEl.checked&&(curTab==='stats'||curTab==='history'))renderTab('blocklist');Filter.reset();Notif.show(t('filter.savedToast'),'success');return;}
}
S.setGlobal(g2);
}
Filter.reset();doFilter(Obs.cards());Notif.show(t('filter.savedToast'),'success');
});
}
if (id==='stats') {
var rst=qs('#ytf-rst');
if(rst) rst.addEventListener('click',function(){if(!confirm(t('stat.clearConfirm')))return;S.resetStats();renderTab('stats');Notif.show(t('stat.cleared'),'success');});
}
if (id==='history') {
var hclr=qs('#ytf-hclr');
if(hclr) hclr.addEventListener('click',function(){if(!confirm(t('hist.clearConfirm')))return;S.clearHistory();renderTab('history');Notif.show(t('hist.cleared'),'success');});
}
if (id==='about') {
// 關於頁面無需事件綁定
}
if (id==='io') {
var gm=function(){var el=qs('#ytf-im');return el?el.value:'merge';};
var fev=function(elId,fn){var el=qs(elId);if(el)el.addEventListener('click',fn);};
fev('#ytf-ej',IO.exportJSON); fev('#ytf-eb',IO.exportBlocklistCSV);
fev('#ytf-ew',IO.exportWhitelistCSV); fev('#ytf-ek',IO.exportKeywordsCSV);
fev('#ytf-est',IO.exportStatsJSON);
fev('#ytf-ehi',IO.exportHistoryCSV);
// 分享連結按鈕
function showShareLink(what) {
var url = IO.generateShareLink(what);
if(!url){ Notif.show(t('io.genLinkFail'),'error'); return; }
// 連結過長警告(超過 1800 字元可能在部分瀏覽器截斷)
var warn = qs('#ytf-share-warn');
if(warn) {
if(url.length > 1800) {
warn.textContent = t('io.linkTooLong')+url.length+t('io.linkTooLongSuffix');
warn.classList.remove('ytf-hid');
} else {
warn.classList.add('ytf-hid');
}
}
var out = qs('#ytf-share-out'), inp2 = qs('#ytf-share-url');
if(out&&inp2){ out.classList.remove('ytf-hid'); inp2.value=url; inp2.select(); }
}
fev('#ytf-sh-bl',function(){showShareLink('blocklist');});
fev('#ytf-sh-wl',function(){showShareLink('whitelist');});
fev('#ytf-sh-kw',function(){showShareLink('keywords');});
fev('#ytf-sh-all',function(){showShareLink('all');});
fev('#ytf-sh-copy',function(){
var inp2=qs('#ytf-share-url');
if(!inp2) return;
if(navigator.clipboard){ navigator.clipboard.writeText(inp2.value).then(function(){Notif.show(t('io.copied'),'success');}); }
else { inp2.select(); document.execCommand('copy'); Notif.show(t('io.copied'),'success'); }
});
// 檢查是否有待匯入的分享清單
try {
var pending = sessionStorage.getItem('ytf_pending_share');
if(pending) {
var pdata = JSON.parse(pending);
var pItems=[];
if(pdata.bl) pItems.push(t('io.label.blocklist')+pdata.bl.length+t('common.channels'));
if(pdata.wl) pItems.push(t('io.label.whitelist')+pdata.wl.length+t('common.channels'));
if(pdata.kg) pItems.push(t('io.label.keywords')+pdata.kg.length+t('common.groups'));
var pNote = h('div',{cls:'ytf-iob',style:'border-color:var(--gold)'},[
h('h4',{style:'margin:0 0 6px;color:var(--gold)'},[t('io.sharedImported')]),
h('div',{style:'font-size:13px;margin-bottom:8px'},[pItems.join('、')]),
h('div',{style:'display:flex;gap:8px'},[
btn('ytf-bp','ytf-sh-imp',t('io.importJson')),
btn('ytf-bg','ytf-sh-dis',t('common.skip'))
])
]);
var ct2=qs('#ytf-ct'); if(ct2) ct2.insertBefore(pNote, ct2.firstChild);
fev('#ytf-sh-imp',function(){
if(pdata.bl){ var bl=S.getBlocklist(); pdata.bl.forEach(function(c){if(!bl.find(function(x){return x.id===c.id;}))bl.push(Object.assign({tags:[]},c));});S.setBlocklist(bl);}
if(pdata.wl){ var wl=S.getWhitelist(); pdata.wl.forEach(function(c){if(!wl.find(function(x){return x.id===c.id;}))wl.push(c);});S.setWhitelist(wl);}
if(pdata.kg){ var kg=S.getKeyGroups(); pdata.kg.forEach(function(g){if(!kg.find(function(x){return x.id===g.id;}))kg.push(g);});S.setKeyGroups(kg);}
sessionStorage.removeItem('ytf_pending_share');
Filter.reset(); Notif.show(t('io.sharedImported'),'success'); renderTab('io');
});
fev('#ytf-sh-dis',function(){ sessionStorage.removeItem('ytf_pending_share'); if(pNote.parentNode)pNote.parentNode.removeChild(pNote); });
}
} catch(e){}
function fon(sel,fn){var el=qs(sel);if(el)el.addEventListener('change',function(ev){if(ev.target.files[0])fn(ev.target.files[0]);});}
fon('#ytf-ij',function(f){IO.importJSON(f,gm()).then(function(){renderTab('io');});});
fon('#ytf-ib',function(f){IO.importBlocklistCSV(f).then(function(){renderTab('io');});});
fon('#ytf-ik',function(f){IO.importKeywordsCSV(f,gm()).then(function(){renderTab('io');});});
var ab=qs('#ytf-ab'); if(ab) ab.addEventListener('change',function(){var g=S.getGlobal();g.autoBackup=ab.checked;S.setGlobal(g);});
var bi=qs('#ytf-bi2'); if(bi) bi.addEventListener('change',function(){var g=S.getGlobal();g.backupInterval=Number(bi.value);S.setGlobal(g);});
var bkpfx=qs('#ytf-bkpfx'); if(bkpfx) bkpfx.addEventListener('change',function(){var g=S.getGlobal();g.backupPrefix=bkpfx.value.trim();S.setGlobal(g);});
}
}
// ── Whitelist ──
function makeWhitelist() {
var list=S.getWhitelist();
var ws=inp('ytf-ws',t('channel.searchPlaceholder'));
var wa=btn('ytf-bp','ytf-wa',t('common.add'));
var wclearBtn=btn('ytf-bg','ytf-wh-clr',t('channel.clearAll'));
var wn=inp('ytf-wn',t('channel.name'));
var wi=inp('ytf-wi',t('channel.handleShort'));
var wlReChk=h('input',{type:'checkbox',id:'ytf-wl-re'});
// v1.1.0: 白名單新增表單加入 tags 輸入
var wtagsIn=inp('ytf-wt',t('channel.tagsHint'));
var wok=btn('ytf-bp','ytf-wok',t('common.confirm'));
var wcl=btn('ytf-bg','',t('common.cancel'));
var wf=h('div',{cls:'ytf-form ytf-hid',id:'ytf-wf'},[
wn,wi,wtagsIn,
h('label',{cls:'ytf-cb',style:'font-size:12px'},[wlReChk,t('channel.regexMode')]),
h('small',{cls:'ytf-sid',style:'padding-left:22px'},[t('channel.regexHint')]),
row(wok,wcl)
]);
// v1.1.0: 白名單也加上標籤篩選 bar(與隱藏名單同架構)
var tbw = buildTagBarWrapper('white', list, ws, 'ytf-wlist');
tbw.render();
return h('div',{cls:'ytf-pane'},[
row(ws,wa,list.length?wclearBtn:null),
wf,
tbw.tagBar,
tbw.listWrap,
h('small',{cls:'ytf-sid'},[t('common.total')+list.length+t('common.channels')])
]);
}
// ── Keywords ──
function makeKeywords() {
var groups=S.getKeyGroups();
var ga=btn('ytf-bp','ytf-ga',t('kw.newGroup'));
var gn=inp('ytf-gn',t('kw.groupNamePh'));
var sct=h('input',{type:'checkbox',id:'ytf-sct'}); sct.checked=true;
var scd=h('input',{type:'checkbox',id:'ytf-scd'});
var gok=btn('ytf-bp','ytf-gok',t('common.create'));
var gcl=btn('ytf-bg','',t('common.cancel'));
var gf=h('div',{cls:'ytf-form ytf-hid',id:'ytf-gf'},[
gn,
row(h('label',{cls:'ytf-cb'},[sct,t('kw.scope.title')]),h('label',{cls:'ytf-cb'},[scd,t('kw.scope.description')])),
row(gok,gcl)
]);
// v1.1.0: 關鍵字搜尋框(搜尋群組名、關鍵字本體)
var kwSearch = h('input',{type:'text',cls:'ytf-in',id:'ytf-kw-search',placeholder:t('kw.searchPh')});
var glist=h('div',{id:'ytf-glist'});
// 依搜尋字串過濾群組 + 標記命中的關鍵字
function renderGroups(q) {
var query = (q||'').trim().toLowerCase();
while(glist.firstChild) glist.removeChild(glist.firstChild);
var curGroups = S.getKeyGroups();
if(!curGroups.length){
glist.appendChild(h('div',{cls:'ytf-mt'},[t('kw.noGroups')]));
return;
}
var matchedCount = 0;
curGroups.forEach(function(g){
if(!query){
glist.appendChild(makeGroupCard(g));
matchedCount++;
return;
}
// 群組命中條件:群組名含 query 或任一關鍵字含 query
var groupNameHit = g.name.toLowerCase().indexOf(query) !== -1;
var matchedKws = g.keywords.filter(function(kw){ return kw.value.toLowerCase().indexOf(query) !== -1; });
if(groupNameHit || matchedKws.length){
// 傳入 query 讓 makeGroupCard 高亮命中的關鍵字
glist.appendChild(makeGroupCard(g, query));
matchedCount++;
}
});
if(!matchedCount){
glist.appendChild(h('div',{cls:'ytf-mt'},[t('channel.noResults')]));
}
}
// 綁定搜尋框 input 事件(即時搜尋)
var searchDeb;
kwSearch.addEventListener('input',function(){
clearTimeout(searchDeb);
searchDeb = setTimeout(function(){ renderGroups(kwSearch.value); }, 150);
});
// 初次渲染
renderGroups('');
return h('div',{cls:'ytf-pane'},[
ga,
gf,
h('div',{cls:'ytf-kw-search-wrap'},[kwSearch]),
glist
]);
}
function makeGroupCard(g, query) {
var tog=h('input',{type:'checkbox',cls:'ytf-gt'}); tog.checked=g.enabled; tog.setAttribute('data-id',g.id);
var delBtn=btn('ytf-bg ytf-gd','',t('kw.delGroup')); delBtn.setAttribute('data-id',g.id); delBtn.style.fontSize='12px';
var kws=h('div',{cls:'ytf-kws'});
var q = (query||'').toLowerCase();
g.keywords.forEach(function(kw,i){
var xBtn=h('button',{cls:'ytf-kx'},['✕']); xBtn.setAttribute('data-gid',g.id); xBtn.setAttribute('data-i',String(i));
var isHit = q && kw.value.toLowerCase().indexOf(q) !== -1;
var chipCls = 'ytf-kw' + (kw.isRegex?' ytf-kwr':'') + (isHit?' ytf-kw-hit':'');
var chip=h('span',{cls:chipCls},[]);
if(kw.isRegex) chip.appendChild(h('span',{cls:'ytf-re'},['Re']));
chip.appendChild(document.createTextNode(kw.value));
chip.appendChild(xBtn);
kws.appendChild(chip);
});
var kaBtn=btn('ytf-ka','',t('kw.addKwBtn')); kaBtn.setAttribute('data-gid',g.id);
kws.appendChild(kaBtn);
var kvIn=h('input',{type:'text',cls:'ytf-in ytf-kv',placeholder:t('kw.kwPlaceholder')});
var rcChk=h('input',{type:'checkbox',cls:'ytf-rc'});
var koBtn=btn('ytf-bp ytf-ko','',t('common.add')); koBtn.setAttribute('data-gid',g.id);
var kiRow=h('div',{cls:'ytf-kr ytf-hid',id:'ytf-ki-'+g.id},[kvIn,h('label',{cls:'ytf-cb'},[rcChk,' Regex']),koBtn]);
var groups2 = S.getKeyGroups();
var gIdx = groups2.findIndex(function(x){ return x.id===g.id; });
var upBtn = h('button',{cls:'ytf-sort-btn',title:t('common.up')},['↑']);
var dnBtn = h('button',{cls:'ytf-sort-btn',title:t('common.down')},['↓']);
if(gIdx===0) upBtn.disabled = true;
if(gIdx===groups2.length-1) dnBtn.disabled = true;
upBtn.addEventListener('click',function(ev){ ev.stopPropagation(); S.moveKeyGroup(g.id,'up'); Filter.reset(); renderTab('keywords'); });
dnBtn.addEventListener('click',function(ev){ ev.stopPropagation(); S.moveKeyGroup(g.id,'down'); Filter.reset(); renderTab('keywords'); });
// 群組標題高亮(若群組名命中搜尋)
var groupNameHit = q && g.name.toLowerCase().indexOf(q) !== -1;
var groupNameEl = h('b', groupNameHit?{cls:'ytf-grp-name-hit'}:{}, [g.name]);
// v1.1.0: 新增:群組編輯功能(群組名、套用範圍)
var editBtn = h('button',{cls:'ytf-grp-edit-btn',title:t('common.edit')},['✏️']);
var editRow = h('div',{cls:'ytf-grp-edit-row ytf-hid'});
(function(){
var eNameIn = h('input',{type:'text',cls:'ytf-in',placeholder:t('kw.groupNamePh')});
eNameIn.value = g.name || '';
var eScTitle = h('input',{type:'checkbox'});
var eScDesc = h('input',{type:'checkbox'});
eScTitle.checked = (g.scope||[]).indexOf('title') >= 0;
eScDesc.checked = (g.scope||[]).indexOf('description') >= 0;
var eSave = h('button',{cls:'ytf-bp',style:'font-size:12px;padding:5px 10px'},[t('common.save')]);
var eCancel = h('button',{cls:'ytf-bg',style:'font-size:12px;padding:5px 10px'},[t('common.cancel')]);
eSave.addEventListener('click',function(ev){
ev.stopPropagation();
var newName = eNameIn.value.trim();
if(!newName){ Notif.show(t('toast.channelEmpty'),'warning'); return; }
var newScope = [];
if(eScTitle.checked) newScope.push('title');
if(eScDesc.checked) newScope.push('description');
if(!newScope.length){ Notif.show(t('kw.scopeRequired'),'warning'); return; }
S.updateKeyGroup(g.id, {name:newName, scope:newScope});
Filter.reset();
renderTab('keywords');
Notif.show(t('toast.updated')+newName,'success');
});
eCancel.addEventListener('click',function(ev){
ev.stopPropagation();
editRow.classList.add('ytf-hid');
});
setChildren(editRow, [
h('div',{cls:'ytf-grp-edit-fields'},[
h('label',{style:'font-size:11px;color:var(--t2);font-weight:600'},[t('kw.groupNameLabel')]),
eNameIn,
h('label',{style:'font-size:11px;color:var(--t2);font-weight:600;margin-top:6px;display:block'},[t('kw.scopeLabel')]),
h('div',{style:'display:flex;gap:16px;margin-top:4px'},[
h('label',{cls:'ytf-cb',style:'font-size:12px'},[eScTitle, t('kw.scope.title')]),
h('label',{cls:'ytf-cb',style:'font-size:12px'},[eScDesc, t('kw.scope.description')])
]),
h('div',{cls:'ytf-row',style:'margin-top:8px'},[eSave, eCancel])
])
]);
})();
editBtn.addEventListener('click',function(ev){
ev.stopPropagation();
// 關閉其他開啟的編輯列
ctEl && ctEl.querySelectorAll('.ytf-grp-edit-row:not(.ytf-hid)').forEach(function(r){
if(r!==editRow) r.classList.add('ytf-hid');
});
editRow.classList.toggle('ytf-hid');
});
return h('div',{cls:'ytf-grp'},[
h('div',{style:'display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px'},[
h('div',{style:'display:flex;align-items:center;gap:8px'},[
h('label',{cls:'ytf-sw ytf-sw-sm'},[tog,h('span',{cls:'ytf-sl'})]),
groupNameEl,
h('small',{cls:'ytf-sid'},[g.scope.join('+')+' · '+g.keywords.length+t('kw.countSuffix')])
]),
h('div',{style:'display:flex;align-items:center;gap:4px'},[upBtn, dnBtn, editBtn, delBtn])
]),
editRow,
kws, kiRow
]);
}
// ── Filters ──
function makeFilters() {
var fs=S.getFilters();
// 白名單獨享模式開關(置頂,最顯眼)
var wlOnlyChk=h('input',{type:'checkbox',id:'ytf-wlo'}); wlOnlyChk.checked=!!fs.whitelistOnly;
var wlOnlyCard=h('div',{cls:'ytf-fs'},[
h('div',{cls:'ytf-fh',style:'background:'+(fs.whitelistOnly?'rgba(255,193,7,.12)':'')},[
h('div',{},[
h('span',{},[t('filter.wlOnly')]),
h('small',{cls:'ytf-sid',style:'margin-top:3px'},[t('filter.wlOnly.hint')])
]),
h('label',{cls:'ytf-sw ytf-sw-sm'},[wlOnlyChk,h('span',{cls:'ytf-sl'})])
])
]);
function sec(title, togId, enabled, bodyChildren) {
var tog=h('input',{type:'checkbox',id:togId}); tog.checked=enabled;
var hd=h('div',{cls:'ytf-fh'},[h('span',{},[title]),h('label',{cls:'ytf-sw ytf-sw-sm'},[tog,h('span',{cls:'ytf-sl'})])]);
var bd=bodyChildren?h('div',{cls:'ytf-fb'+(enabled?'':' ytf-dis'),id:togId+'-bd'},bodyChildren):null;
return h('div',{cls:'ytf-fs'},bd?[hd,bd]:[hd]);
}
var dmin=numInp('ytf-dmin',fs.duration.min,0), dmax=numInp('ytf-dmax',fs.duration.max,0);
var ll=h('input',{type:'checkbox',id:'ytf-ll'}); ll.checked=fs.live.hideLive;
var lu=h('input',{type:'checkbox',id:'ytf-lu'}); lu.checked=fs.live.hideUpcoming;
var lm=h('select',{id:'ytf-lm',cls:'ytf-sel'},[
h('option',{value:'block'},[t('filter.lang.modeBlock')]),h('option',{value:'allow'},[t('filter.lang.modeAllow')])
]);
lm.value=fs.language.mode;
// 常用語言清單(勾選框)
var COMMON_LANGS=[
['ja',t('lang.ja')],['ko',t('lang.ko')],['zh',t('lang.zh')],['en',t('lang.en')],
['ru',t('lang.ru')],['ar',t('lang.ar')],['es',t('lang.es')],['fr',t('lang.fr')],
['de',t('lang.de')],['pt',t('lang.pt')],['hi',t('lang.hi')],['th',t('lang.th')],
['vi',t('lang.vi')],['id',t('lang.id')],['tr',t('lang.tr')],['it',t('lang.it')]
];
var langCks=COMMON_LANGS.map(function(l){
var ck=h('input',{type:'checkbox',cls:'ytf-lk',value:l[0]});
if(fs.language.langs.indexOf(l[0])>=0) ck.checked=true;
return h('label',{cls:'ytf-cb ytf-lbl-lang'},[ck,' '+l[1]]);
});
// 自訂語言代碼輸入
var customLangs = (fs.language.customLangs||[]).join(', ');
var customIn = h('input',{type:'text',cls:'ytf-in',id:'ytf-lcustom',placeholder:t('filter.lang.custom')});
customIn.value = customLangs;
// BCP-47 查詢連結(用 GM_openInTab 或 window.open 避免 CSP 限制)
var refLink = h('button',{cls:'ytf-lang-ref',id:'ytf-lang-ref-btn',title:t('filter.lang.refTip')},[t('filter.lang.ref')]);
refLink.addEventListener('click',function(){
window.open('https://r12a.github.io/app-subtags/', '_blank', 'noopener');
});
var saveBtn=btn('ytf-bp','ytf-fsave',t('filter.saveBtn')); saveBtn.style.width='100%';
// 上傳日期 section(v1.1.1:修正語義歧義 + 新增單位選項)
// 舊版用「Uploaded more than N days ago / Uploaded within N days」label 易讓人誤解
// 新版改為「隱藏 __ 的影片」明確句式,並提供 天/年 雙單位(不用計算 1461 這種數字)
var dateSec = (function(){
var ud = fs.uploadDate || {};
// 將舊的 days 值換算為顯示用的 amount + unit
var storedDays = ud.days || 365;
var storedUnit = ud.unit; // 如果已經是新格式則用新格式
var displayAmount, displayUnit;
if(storedUnit==='year' || storedUnit==='day'){
displayUnit = storedUnit;
displayAmount = ud.amount || (storedUnit==='year' ? Math.round(storedDays/365) : storedDays);
} else {
// 舊版資料:若 days 是 365 的倍數,顯示為年
if(storedDays % 365 === 0 && storedDays >= 365){
displayUnit = 'year';
displayAmount = storedDays / 365;
} else {
displayUnit = 'day';
displayAmount = storedDays;
}
}
var udAmountIn = numInp('ytf-ud-amount', displayAmount, 1);
var udUnitSel = h('select',{id:'ytf-ud-unit',cls:'ytf-sel'},[
h('option',{value:'day'},[t('filter.date.unit.day')]),
h('option',{value:'year'},[t('filter.date.unit.year')])
]);
udUnitSel.value = displayUnit;
var udCond = h('select',{id:'ytf-ud-cond',cls:'ytf-sel'},[
h('option',{value:'older'},[t('filter.date.hideOlder')]),
h('option',{value:'newer'},[t('filter.date.hideNewer')])
]);
udCond.value = ud.condition || 'older';
return sec(t('filter.date'),'ytf-ud', ud.enabled||false, [
row(
h('label',{style:'flex:1'},[t('filter.date.hide')]),
udCond, udAmountIn, udUnitSel
),
h('small',{cls:'ytf-sid'},[t('filter.date.hint')])
]);
})();
// 全域設定 section(原本 IIFE 抽出)
var globalSec = (function(){
var gs2=S.getGlobal();
var toastChk=h('input',{type:'checkbox',id:'ytf-toast'}); toastChk.checked=gs2.showToast!==false;
var statsChk=h('input',{type:'checkbox',id:'ytf-estats'}); statsChk.checked=gs2.enableStats!==false;
var langSel=h('select',{id:'ytf-lang-sel',cls:'ytf-sel',style:'min-width:120px'},[
h('option',{value:'zh'},[t('set.lang.zh')]),
h('option',{value:'en'},['English'])
]);
langSel.value = LANG;
return h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 10px;font-size:13px'},[t('set.global')]),
h('div',{style:'display:flex;flex-direction:column;gap:10px'},[
// 語言選擇置於全域設定頂部(高優先級、使用者最常調整)
h('div',{style:'display:flex;align-items:center;gap:8px;padding-bottom:10px;border-bottom:1px dashed var(--bd)'},[
h('span',{style:'flex:1;font-size:13px'},[t('set.lang')]),
langSel
]),
h('label',{cls:'ytf-cb'},[toastChk,t('set.toast')]),
h('small',{cls:'ytf-sid',style:'margin-top:-6px;padding-left:22px'},[t('set.toastHint')]),
h('label',{cls:'ytf-cb'},[statsChk,t('set.stats')]),
h('small',{cls:'ytf-sid',style:'margin-top:-6px;padding-left:22px'},[t('set.statsHint')])
])
]);
})();
// D-5 修訂:設定 Tab 最終分群(儲存置頂免捲動、全域設定次位、過濾器分群)
// (1) 💾 儲存設定(置頂,修改後立即可存,不用往下拉)
// (2) ⚙️ 全域設定(含介面語言切換)
// (3) ⭐ 白名單獨享(頻道類)
// (4) ⏱📡👑📅👁 影片屬性類
// (5) 🌐 語言過濾(內容類)
return h('div',{cls:'ytf-pane'},[
// ═ 儲存(置頂)═
saveBtn,
h('hr',{cls:'ytf-divider'}),
// ═ 系統類 ═
globalSec,
h('hr',{cls:'ytf-divider'}),
// ═ 頻道類 ═
wlOnlyCard,
h('hr',{cls:'ytf-divider'}),
// ═ 影片屬性類 ═
sec(t('filter.duration'),'ytf-dur',fs.duration.enabled,[row(h('label',{style:'flex:1'},[t('filter.duration.min')]),dmin),row(h('label',{style:'flex:1'},[t('filter.duration.max')]),dmax)]),
sec(t('filter.live'),'ytf-live',fs.live.enabled,[h('label',{cls:'ytf-cb'},[ll,t('filter.live.hideLive')]),h('label',{cls:'ytf-cb'},[lu,t('filter.live.hideUpcoming')])]),
sec(t('filter.member'),'ytf-mem',fs.member.enabled,null),
dateSec,
sec(t('filter.views'),'ytf-views',(fs.views&&fs.views.enabled)||false,[
row(h('label',{style:'flex:1'},[t('filter.views.min')]),numInp('ytf-vmin',(fs.views&&fs.views.min)||0,0)),
row(h('label',{style:'flex:1'},[t('filter.views.max')]),numInp('ytf-vmax',(fs.views&&fs.views.max)||0,0)),
h('small',{cls:'ytf-sid'},[t('filter.views.hint')])
]),
h('hr',{cls:'ytf-divider'}),
// ═ 內容類 ═
sec(t('filter.lang'),'ytf-lang',fs.language.enabled,[
row(h('label',{style:'flex:1'},[t('common.mode')]),lm),
h('div',{cls:'ytf-langs'},langCks),
h('div',{style:'margin-top:10px;display:flex;flex-direction:column;gap:6px'},[
h('label',{style:'font-size:12px;color:var(--t2)'},[t('filter.lang.customLabel')]),
customIn,
refLink,
h('small',{cls:'ytf-sid'},[t('help.language2')])
])
])
]);
}
// ── Stats ──
function makeStats() {
var st=S.getStats();
var kws=Object.entries(st.keywordHits||{}).sort(function(a,b){return b[1]-a[1];}).slice(0,15);
var chs=Object.entries(st.channelHits||{}).sort(function(a,b){return b[1]-a[1];}).slice(0,15);
// 解碼頻道 ID(可能是 URL encode 的中文路徑)
function decodeChId(raw) {
try {
// 優先從統計的 channelNames 快取查詢(即使頻道已從隱藏名單移除也能顯示)
var st2=S.getStats();
if(st2.channelNames&&st2.channelNames[raw]) return st2.channelNames[raw];
var decoded = decodeURIComponent(raw);
if(st2.channelNames&&st2.channelNames[decoded]) return st2.channelNames[decoded];
// Fallback:從隱藏名單 / 白名單查詢
var bl = S.getBlocklist().concat(S.getWhitelist());
var found = bl.find(function(c){ return c.id === raw || c.id === decoded; });
if (found && found.name) return found.name;
return decoded;
} catch(e) { return raw; }
}
function makeStatRow(label, count, cls) {
return h('div',{cls:'ytf-sr2'},[
h('div',{cls:'ytf-sr2-bar-wrap'},[
h('div',{cls:'ytf-sr2-bar'+(cls?' '+cls:''),style:'width:'+count+'%'})
]),
h('span',{cls:'ytf-sr2-label',title:label},[label]),
h('span',{cls:'ytf-sr2-count'},[String(count)])
]);
}
// 計算最大值(用於百分比 bar)
var kwMax = kws.length ? kws[0][1] : 1;
var chMax = chs.length ? chs[0][1] : 1;
var kwSection = h('div',{cls:'ytf-stat-sec'},[
h('div',{cls:'ytf-stat-sec-hd'},[
h('span',{},[t('stat.topKw')]),
h('span',{cls:'ytf-stat-total'},[kws.length ? t('common.total')+kws.reduce(function(s,e){return s+e[1];},0)+t('common.times') : ''])
])
]);
if (kws.length) {
kws.forEach(function(e){
kwSection.appendChild(h('div',{cls:'ytf-sr2'},[
h('div',{cls:'ytf-sr2-row'},[
h('span',{cls:'ytf-sr2-label',title:e[0]},[e[0]]),
h('span',{cls:'ytf-sr2-count'},[String(e[1])+t('common.times')])
]),
h('div',{cls:'ytf-sr2-bar-wrap'},[
h('div',{cls:'ytf-sr2-bar ytf-sr2-kw',style:'width:'+Math.round(e[1]/kwMax*100)+'%'})
])
]));
});
} else {
kwSection.appendChild(h('div',{cls:'ytf-mt'},[t('stat.noData')]));
}
var chSection = h('div',{cls:'ytf-stat-sec'},[
h('div',{cls:'ytf-stat-sec-hd'},[
h('span',{},[t('stat.topCh')]),
h('span',{cls:'ytf-stat-total'},[chs.length ? t('common.total')+chs.reduce(function(s,e){return s+e[1];},0)+t('common.times') : ''])
])
]);
if (chs.length) {
chs.forEach(function(e){
var label = decodeChId(e[0]);
chSection.appendChild(h('div',{cls:'ytf-sr2'},[
h('div',{cls:'ytf-sr2-row'},[
h('span',{cls:'ytf-sr2-label',title:label},[label]),
h('span',{cls:'ytf-sr2-count'},[String(e[1])+t('common.times')])
]),
h('div',{cls:'ytf-sr2-bar-wrap'},[
h('div',{cls:'ytf-sr2-bar ytf-sr2-ch',style:'width:'+Math.round(e[1]/chMax*100)+'%'})
])
]));
});
} else {
chSection.appendChild(h('div',{cls:'ytf-mt'},[t('stat.noData')]));
}
var rst=btn('ytf-bg','ytf-rst',t('stat.clear'));
return h('div',{cls:'ytf-pane'},[
h('div',{cls:'ytf-sn'},[
h('span',{style:'font-size:48px;font-weight:800;display:block;line-height:1'},[String(st.totalHidden||0)]),
h('span',{style:'font-size:13px;opacity:.85'},[t('stat.totalHidden')])
]),
// D-3: 關鍵字與頻道排行兩欄並排
h('div',{cls:'ytf-stat-grid'},[kwSection, chSection]),
rst
]);
}
// ── History ──
function makeHistory() {
var hist=S.getHistory();
var hclr=btn('ytf-bg','ytf-hclr',t('hist.clear'));
var items;
if (hist.length) {
var ul=h('ul',{cls:'ytf-hl'});
hist.slice(0,100).forEach(function(r){
ul.appendChild(h('li',{cls:'ytf-hi'},[
h('div',{},[h('b',{cls:'ytf-ht'},[r.title||t('hist.unknownTitle')]),h('small',{cls:'ytf-sid'},[r.channelName||(function(){try{return decodeURIComponent(r.channelId||'');}catch(e){return r.channelId||'';}})()])]),
h('div',{style:'display:flex;align-items:center;gap:8px;white-space:nowrap'},[
h('span',{cls:'ytf-badge'},[Notif.reasonText(r.reason)]),
h('small',{cls:'ytf-sid'},[fmtTime(r.hiddenAt)])
])
]));
});
items=ul;
} else {
items=h('div',{cls:'ytf-mt'},[t('hist.empty')]);
}
return h('div',{cls:'ytf-pane'},[
h('div',{style:'display:flex;align-items:center;gap:10px'},[h('small',{cls:'ytf-sid'},[t('common.total')+hist.length+t('common.items')]),hclr]),
items
]);
}
// ── IO ──
function makeIO() {
var gs=S.getGlobal();
var imSel=h('select',{id:'ytf-im',cls:'ytf-sel'},[h('option',{value:'merge'},[t('io.merge')]),h('option',{value:'overwrite'},[t('io.replace')])]);
function fileLbl(text, inputId, accept) {
var fi=h('input',{type:'file',id:inputId,accept:accept,style:'display:none'});
var lbl=h('label',{cls:'ytf-bg',style:'cursor:pointer'},[text,fi]);
return lbl;
}
var ab=h('input',{type:'checkbox',id:'ytf-ab'}); ab.checked=gs.autoBackup;
var bi=numInp('ytf-bi2',gs.backupInterval||7,1); bi.max='30';
var gs2=S.getGlobal();
var pfxIn=h('input',{type:'text',cls:'ytf-in',id:'ytf-bkpfx',placeholder:t('io.backupFilenameHint')});
pfxIn.value = gs2.backupPrefix||'';
return h('div',{cls:'ytf-pane'},[
// ── 1. 設定備份(完整 JSON + 自動備份)────────────────
h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 8px'},[t('io.backup')]),
h('small',{cls:'ytf-sid',style:'margin-bottom:8px;display:block'},
[t('io.backupDesc')]),
h('div',{style:'display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px'},[
btn('ytf-bp','ytf-ej','📥 ' + t('io.exportJson'))
]),
h('div',{style:'border-top:1px solid var(--bd);padding-top:10px;margin-top:4px'},[
h('label',{cls:'ytf-cb',style:'margin-bottom:6px'},[ab,t('io.autoBackup')]),
h('div',{style:'display:flex;align-items:center;gap:8px;margin-top:6px'},[
h('label',{style:'flex:1;font-size:13px'},[t('io.backupInterval')]),bi
]),
h('div',{style:'display:flex;flex-direction:column;gap:4px;margin-top:8px'},[
h('label',{style:'font-size:12px;color:var(--t2)'},[t('io.backupFilename')]),
pfxIn,
h('small',{cls:'ytf-sid'},[t('io.backupPrefixHint')])
])
])
]),
// ── 2. 個別資料匯出 ────────────────────────────────────
h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 8px'},[t('io.stat.section')]),
h('small',{cls:'ytf-sid',style:'margin-bottom:8px;display:block'},
[t('io.csvHint')]),
h('div',{style:'display:flex;flex-wrap:wrap;gap:8px'},[
btn('ytf-bg','ytf-eb',t('tab.blocklist') + ' CSV'),
btn('ytf-bg','ytf-ew',t('tab.whitelist') + ' CSV'),
btn('ytf-bg','ytf-ek',t('tab.keywords') + ' CSV')
])
]),
// ── 3. 統計與歷史匯出 ──────────────────────────────────
h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 8px'},[t('io.stat.section')]),
h('small',{cls:'ytf-sid',style:'margin-bottom:8px;display:block'},
[t('io.statHint')]),
h('div',{style:'display:flex;flex-wrap:wrap;gap:8px'},[
btn('ytf-bg','ytf-est',t('tab.stats') + ' JSON'),
btn('ytf-bg','ytf-ehi',t('tab.history') + ' CSV')
])
]),
// ── 4. 匯入 ────────────────────────────────────────────
h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 8px'},['📥 ' + t('common.add')]),
h('div',{style:'display:flex;align-items:center;gap:8px;margin-bottom:10px'},[
h('label',{style:'flex:1'},[t('io.mergeMode')]),imSel
]),
h('div',{style:'display:flex;flex-wrap:wrap;gap:8px'},[
fileLbl(t('io.backup'),'ytf-ij','.json'),
fileLbl(t('tab.blocklist') + ' CSV','ytf-ib','.csv'),
fileLbl(t('tab.keywords') + ' CSV','ytf-ik','.csv')
])
]),
// ── 5. 分享過濾清單 ────────────────────────────────────
h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 8px'},[t('io.share')]),
h('small',{cls:'ytf-sid',style:'margin-bottom:8px;display:block'},
[t('io.shareDesc')]),
h('div',{style:'display:flex;flex-wrap:wrap;gap:8px'},[
btn('ytf-bg','ytf-sh-bl',t('io.shareBlock')),
btn('ytf-bg','ytf-sh-wl',t('io.shareWhite')),
btn('ytf-bg','ytf-sh-kw',t('io.shareKw')),
btn('ytf-bp','ytf-sh-all',t('common.all'))
]),
h('div',{id:'ytf-share-warn',cls:'ytf-hid',style:'font-size:12px;color:var(--gold);margin-top:4px'},['']),
h('div',{id:'ytf-share-out',cls:'ytf-hid',style:'margin-top:8px'},[
h('div',{style:'display:flex;gap:6px'},[
h('input',{type:'text',cls:'ytf-in',id:'ytf-share-url',readonly:'',placeholder:t('io.share')}),
btn('ytf-bg','ytf-sh-copy',t('common.copy'))
]),
h('small',{cls:'ytf-sid'},[t('io.shareHint2')])
])
])
]);
}
// ── About ──
function makeAbout() {
function sec(title, items) {
var itemEls = items.map(function(item) {
return h('li',{style:'margin-bottom:6px'},[item]);
});
var ul = h('ul',{style:'margin:8px 0 0 0;padding-left:20px;display:flex;flex-direction:column;gap:4px'},itemEls);
return h('div',{cls:'ytf-iob'},[h('h4',{style:'margin:0 0 8px;font-size:13px'},[title]),ul]);
}
function secWithIntro(title, intro, items) {
var ul = h('ul',{style:'margin:8px 0 0 0;padding-left:20px;display:flex;flex-direction:column;gap:4px'},
items.map(function(x){return h('li',{style:'margin-bottom:4px'},[x]);})
);
return h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 6px;font-size:13px'},[title]),
intro ? h('p',{style:'margin:0 0 4px;font-size:12px;color:var(--t2);line-height:1.5'},[intro]) : null,
ul
]);
}
function kv(key, val) {
return h('div',{style:'display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--bd);font-size:13px'},[
h('span',{style:'color:var(--t2)'},[key]),
h('span',{style:'font-weight:600'},[val])
]);
}
// Donate 按鈕(Liberapay + Ko-fi 並排)
var donateLiberapay = h('a',{
href:'https://liberapay.com/MayoHu_xu4fu/donate',
target:'_blank',
rel:'noopener noreferrer',
cls:'ytf-donate-btn'
},['❤️ Liberapay']);
var donateKofi = h('a',{
href:'https://ko-fi.com/L4L41XPRRX',
target:'_blank',
rel:'noopener noreferrer',
cls:'ytf-donate-btn ytf-kofi-btn'
},['☕ Ko-fi']);
var donateBtns = h('div',{cls:'ytf-donate-row'},[donateLiberapay, donateKofi]);
return h('div',{cls:'ytf-pane'},[
// 軟體資訊
h('div',{cls:'ytf-iob'},[
h('h4',{style:'margin:0 0 10px;font-size:14px'},[t('about.software')]),
kv(t('about.name'),t('panel.title')),
kv(t('about.version'),'v1.1.1-rc1'),
kv(t('about.compat'),'Tampermonkey (Firefox / Chrome / Edge)'),
kv(t('about.author'),'MayoHu'),
h('div',{style:'display:flex;justify-content:space-between;align-items:center;padding:6px 0 0;font-size:13px'},[
h('span',{style:'color:var(--t2)'},[t('about.source')]),
h('a',{href:'https://github.com/hubig21/YouTube-Gatekeeper',target:'_blank',rel:'noopener noreferrer',cls:'ytf-gh-link'},['🔗 GitHub'])
])
]),
// 快速入門(精簡取代原本 4 大段落)
sec(t('help.quickStart.title'),[
t('help.quickStart.step1'),
t('help.quickStart.step2'),
t('help.quickStart.step3'),
t('help.quickStart.step4'),
]),
// Regex 完整指南
secWithIntro(t('help.regex.title'), t('help.regex.intro'), [
h('strong',{style:'color:var(--t);font-size:12px'},[t('help.regex.syntax.title')]),
t('help.regex.syntax.1'),
t('help.regex.syntax.2'),
t('help.regex.syntax.3'),
t('help.regex.syntax.4'),
t('help.regex.syntax.5'),
t('help.regex.syntax.6'),
t('help.regex.syntax.7'),
h('strong',{style:'color:var(--t);font-size:12px;display:inline-block;margin-top:6px'},[t('help.regex.examples.title')]),
t('help.regex.examples.1'),
t('help.regex.examples.2'),
t('help.regex.examples.3'),
t('help.regex.examples.4'),
t('help.regex.examples.5'),
h('em',{style:'color:var(--t2);font-size:12px;display:inline-block;margin-top:4px'},[t('help.regex.tip')]),
]),
// 兒童保護設定範例
secWithIntro(t('help.kids.title'), t('help.kids.intro'), [
t('help.kids.step1'),
t('help.kids.step2'),
t('help.kids.step3'),
t('help.kids.step4'),
t('help.kids.step5'),
t('help.kids.step6'),
h('strong',{cls:'ytf-wlo-warn',style:'display:inline-block;margin-top:6px;line-height:1.5;font-weight:600'},[t('help.kids.warning')]),
]),
// 快捷操作(精簡)
sec(t('help.shortcuts.title'),[
t('help.shortcuts.fab'),
t('help.shortcuts.cardBar'),
t('help.shortcuts.tmMenu'),
]),
// Donate
h('div',{cls:'ytf-iob ytf-donate-box'},[
h('h4',{style:'margin:0 0 8px;font-size:13px'},[t('about.donate')]),
h('p',{cls:'ytf-donate-txt'},[t('about.donateText')]),
donateBtns,
]),
]);
}
// refreshList:直接更新隱藏名單或白名單的清單 DOM,不重建整個 Tab
function refreshList(type) {
if(type==='blocklist'||type==='block') {
var bl=document.querySelector('#ytf-blist');
if(bl) setChildren(bl,[makeChList(S.getBlocklist(),'block','','')]);
var bc=document.querySelector('#ytf-bl-count');
if(bc) bc.textContent=t('common.total')+S.getBlocklist().length+t('common.channels');
} else if(type==='whitelist'||type==='white') {
var wl=document.querySelector('#ytf-wlist');
if(wl) setChildren(wl,[makeChList(S.getWhitelist(),'white','')]);
}
}
return {build,show,hide,toggle,renderTab,refreshList,getIsOpen:function(){return isOpen;},getCurTab:function(){return curTab;}};
})();
// ============================================================
// FAB
// ============================================================
var FAB = (function() {
var headerBtn = null;
function build() {
// 不建立浮動按鈕,只注入頂欄按鈕
injectHeaderBtn();
}
function injectHeaderBtn() {
var tryInject = function() {
if (headerBtn && document.body.contains(headerBtn)) return;
headerBtn = null; // reset so we can re-inject
// YouTube 頂欄右側 #end 或 #buttons
var end = document.querySelector('#end, #buttons.ytd-masthead');
if (!end) { setTimeout(tryInject, 1500); return; }
// 主按鈕:點擊開啟面板
headerBtn = h('button', {id:'ytf-hb', title:t('fab.title')}, ['🔍']);
headerBtn.addEventListener('click', function() { Panel.toggle(); });
// 右鍵切換過濾器開關
headerBtn.addEventListener('contextmenu', function(ev) {
ev.preventDefault();
var cur = S.isEnabled(); S.setEnabled(!cur);
Filter.reset(); update();
Notif.show(cur ? t('filter.disabled') : t('filter.enabled'));
if (!cur) doFilter(Obs.cards());
});
end.insertBefore(headerBtn, end.firstChild);
update();
};
setTimeout(tryInject, 1200);
}
function update() {
if (!headerBtn) return;
var on = S.isEnabled();
headerBtn.textContent = on ? '🔍' : '⏸';
headerBtn.title = on
? t('fab.tip.enabled')
: t('fab.tip.disabled');
headerBtn.style.opacity = on ? '1' : '0.55';
}
return {build, update, injectHeaderBtn};
})();
// ============================================================
// CSS
// ============================================================
GM_addStyle([
':root{--bg:#0f0f0f;--bg2:#1c1c1c;--bg3:#282828;--bd:#363636;--t:#e8e8e8;--t2:#999;--ac:#ff4444;--gold:#ffc107;--r:10px;--font:"Segoe UI","PingFang TC","Microsoft JhengHei",sans-serif}',
'html:not([dark]){--bg:#fff;--bg2:#f5f5f5;--bg3:#eee;--bd:#ddd;--t:#111;--t2:#666}',
'#ytf-panel{position:fixed;inset:0;z-index:2147483646;pointer-events:none;font-family:var(--font)}',
'#ytf-panel.ytf-open{pointer-events:all}',
'#ytf-ov{position:absolute;inset:0;background:rgba(0,0,0,.55);opacity:0;transition:.25s}',
'#ytf-panel.ytf-open #ytf-ov{opacity:1}',
'#ytf-bd{position:absolute;top:0;right:0;width:620px;max-width:96vw;height:100vh;background:var(--bg);border-left:1px solid var(--bd);display:flex;flex-direction:column;color:var(--t);font-size:14px;transform:translateX(100%);transition:transform .28s cubic-bezier(.4,0,.2,1);overflow:hidden}',
'#ytf-panel.ytf-open #ytf-bd{transform:translateX(0)}',
'#ytf-hd{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--bd);background:var(--bg2);flex-shrink:0}',
'#ytf-logo{font-size:15px;font-weight:700;display:inline-flex;align-items:baseline;gap:8px}',
'#ytf-ver{font-size:10px;font-weight:400;color:var(--t2);opacity:.7;letter-spacing:.5px}',
'#ytf-x{background:none;border:none;color:var(--t2);font-size:18px;cursor:pointer;padding:4px 8px;border-radius:6px}#ytf-x:hover{background:var(--bg3)}',
'#ytf-lb{padding:0 14px;flex-shrink:0;height:44px;overflow:hidden;border-bottom:1px solid var(--bd)}',
'.ytf-l{font-size:11px;padding:2px 0;color:var(--t2)}.ytf-l-success{color:#4caf50}.ytf-l-error{color:var(--ac)}.ytf-l-warning{color:var(--gold)}',
'#ytf-nav{display:flex;overflow-x:auto;border-bottom:1px solid var(--bd);flex-shrink:0;background:var(--bg2);scrollbar-width:none;position:relative;mask-image:linear-gradient(to right,#000 calc(100% - 20px),transparent 100%);-webkit-mask-image:linear-gradient(to right,#000 calc(100% - 20px),transparent 100%)}#ytf-nav::-webkit-scrollbar{display:none}',
'.ytf-tab{padding:7px 8px;background:none;border:none;border-bottom:2px solid transparent;color:var(--t2);cursor:pointer;white-space:nowrap;font-size:12px;font-family:var(--font);transition:.15s}',
'.ytf-tab:hover{color:var(--t);background:var(--bg3)}.ytf-tab-on{color:var(--ac)!important;border-bottom-color:var(--ac)!important}',
'#ytf-ct{flex:1;overflow-y:auto;padding:clamp(8px,2.2%,14px);padding-bottom:24px;scrollbar-width:thin;scrollbar-color:var(--bd) transparent}',
'.ytf-pane{display:flex;flex-direction:column;gap:clamp(8px,1.8%,12px)}',
'.ytf-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
'.ytf-in{flex:1;padding:8px 10px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--t);font-size:13px;font-family:var(--font);outline:none;min-width:0;box-sizing:border-box}.ytf-in:focus{border-color:var(--ac)}',
'.ytf-ins{max-width:90px;flex:none}',
'.ytf-sel{padding:7px 10px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--t);font-size:13px;cursor:pointer}',
'.ytf-bp{padding:8px 12px;background:var(--ac);border:none;border-radius:6px;color:#fff;font-size:13px;font-family:var(--font);cursor:pointer;white-space:nowrap}.ytf-bp:hover{filter:brightness(1.15)}',
'.ytf-bg{padding:8px 12px;background:none;border:1px solid var(--bd);border-radius:6px;color:var(--t);font-size:13px;font-family:var(--font);cursor:pointer;white-space:nowrap}.ytf-bg:hover{background:var(--bg3)}',
'.ytf-hid{display:none!important}',
'.ytf-mt{text-align:center;color:var(--t2);padding:20px;font-size:13px}',
'.ytf-sid{font-size:11px;color:var(--t2);display:block;margin-top:2px}',
'.ytf-form{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:12px;display:flex;flex-direction:column;gap:10px}',
'.ytf-cb{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px}',
'.ytf-tag{display:inline-block;padding:1px 6px;background:var(--bg3);border-radius:10px;font-size:11px;color:var(--t2);margin:1px}',
'.ytf-badge{font-size:11px;padding:2px 7px;background:rgba(255,68,68,.15);color:var(--ac);border-radius:10px;white-space:nowrap}',
'.ytf-cl{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px}',
'.ytf-ci{display:flex;align-items:center;justify-content:space-between;padding:9px 11px;background:var(--bg2);border:1px solid var(--bd);border-radius:8px;gap:8px}.ytf-ci:hover{border-color:var(--ac)}',
// 頻道列表 & 移除確認列
'.ytf-ci-wrap{flex-direction:column;align-items:stretch;padding:0}',
'.ytf-cn{font-size:13px;display:block}',
'.ytf-del{background:none;border:none;cursor:pointer;font-size:14px;padding:3px 6px;color:var(--t2);flex-shrink:0;border-radius:4px;transition:.15s;font-weight:600}',
'.ytf-del:hover{background:rgba(255,68,68,.15);color:var(--ac)}',
'.ytf-del-confirm{display:flex;align-items:center;gap:6px;padding:6px 11px 8px;border-top:1px solid var(--bd);background:rgba(255,68,68,.06)}',
'.ytf-del-confirm-txt{font-size:12px;color:var(--t2);flex:1}',
'.ytf-del-yes{padding:4px 12px;background:var(--ac);border:none;border-radius:5px;color:#fff;font-size:12px;cursor:pointer;font-weight:600}',
'.ytf-del-yes:hover{filter:brightness(1.15)}',
'.ytf-del-no{padding:4px 10px;background:none;border:1px solid var(--bd);border-radius:5px;color:var(--t2);font-size:12px;cursor:pointer}',
'.ytf-del-no:hover{background:var(--bg3)}',
'.ytf-grp{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:12px;display:flex;flex-direction:column;gap:10px}',
'.ytf-kws{display:flex;flex-wrap:wrap;gap:5px;align-items:center}',
'.ytf-kw{display:inline-flex;align-items:center;gap:3px;padding:3px 8px;background:var(--bg3);border:1px solid var(--bd);border-radius:20px;font-size:12px}.ytf-kwr{border-color:#2196f3}',
// v1.1.0: 關鍵字搜尋命中高亮
'.ytf-kw-hit{background:rgba(255,193,7,.3);border-color:var(--gold);box-shadow:0 0 0 1px var(--gold)}',
'.ytf-grp-name-hit{color:var(--gold);text-shadow:0 0 8px rgba(255,193,7,.4)}',
'.ytf-kw-search-wrap{margin:8px 0;padding:0}',
'#ytf-kw-search{width:100%}',
'.ytf-re{font-size:10px;background:#2196f3;color:#fff;padding:1px 4px;border-radius:4px}',
'.ytf-kx{background:none;border:none;cursor:pointer;font-size:11px;color:var(--t2);padding:0 2px}.ytf-kx:hover{color:var(--ac)}',
'.ytf-ka{padding:3px 10px;background:none;border:1px dashed var(--bd);border-radius:20px;color:var(--t2);font-size:12px;cursor:pointer}.ytf-ka:hover{border-color:var(--ac);color:var(--ac)}',
'.ytf-kr{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding-top:4px}.ytf-kv{flex:1;min-width:120px}',
'.ytf-fs{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);overflow:hidden}',
'.ytf-fh{display:flex;align-items:center;justify-content:space-between;padding:9px 13px;font-weight:600}',
'.ytf-fb{padding:9px 13px;border-top:1px solid var(--bd);display:flex;flex-direction:column;gap:8px}.ytf-fb.ytf-dis{opacity:.4;pointer-events:none}',
'.ytf-divider{border:none;border-top:1px solid var(--bd);margin:4px 0}',
'.ytf-stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}',
'.ytf-stat-grid>*{min-width:0}',
'.ytf-sn{background:linear-gradient(135deg,var(--ac),#b00);border-radius:var(--r);padding:16px;text-align:center;color:#fff}',
'.ytf-sb{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:11px}',
'.ytf-sr{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px solid var(--bd);font-size:12px}.ytf-sr:last-child{border:none}',
'.ytf-hl{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px}',
'.ytf-hi{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;padding:9px 11px;background:var(--bg2);border:1px solid var(--bd);border-radius:8px}',
'.ytf-ht{font-size:13px;font-weight:500;display:block;margin-bottom:2px}',
'.ytf-iob{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:clamp(8px,2%,13px);display:flex;flex-direction:column;gap:clamp(6px,1.5%,10px)}',
'.ytf-sw{position:relative;display:inline-block;width:40px;height:22px;flex-shrink:0;margin:0}',
'.ytf-sw input{opacity:0;width:0;height:0;position:absolute}',
'.ytf-sl{position:absolute;inset:0;background:var(--bd);border-radius:22px;cursor:pointer;transition:.2s}',
'.ytf-sl:before{content:"";position:absolute;width:16px;height:16px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}',
'.ytf-sw input:checked+.ytf-sl{background:var(--ac)}.ytf-sw input:checked+.ytf-sl:before{transform:translateX(18px)}',
'.ytf-sw-sm{width:32px;height:18px}.ytf-sw-sm .ytf-sl:before{width:12px;height:12px}.ytf-sw-sm input:checked+.ytf-sl:before{transform:translateX(14px)}',
// Compact 卡片(側邊推薦):小圓形圖示
'.ytf-qa-icons{position:absolute;top:4px;right:4px;display:flex;flex-direction:column;gap:3px;z-index:9999;opacity:0;transition:opacity .15s;pointer-events:none}',
'.ytf-qa-icons.ytf-qa-show{opacity:1;pointer-events:all}',
'.ytf-qa-ic{width:26px;height:26px;border-radius:50%;border:none;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(6px);transition:transform .12s}',
'.ytf-qa-ic:hover{transform:scale(1.18)}',
'.ytf-qa-ic-b{background:rgba(220,50,50,.82)}.ytf-qa-ic-w{background:rgba(255,193,7,.82)}',
// 影片卡片 hover action bar(JS mouseenter 控制顯示)
'.ytf-qa-bar{position:absolute;height:36px;background:rgba(0,0,0,.78);backdrop-filter:blur(8px);display:flex;align-items:stretch;z-index:9999;opacity:0;transition:opacity .15s;pointer-events:none;border-radius:0 0 6px 6px;overflow:hidden}',
'.ytf-qa-bar.ytf-qa-show{opacity:1;pointer-events:all}',
'.ytf-qa-bar button{flex:1;border:none;background:none;cursor:pointer;font-size:12px;color:#fff;display:flex;align-items:center;justify-content:center;gap:5px;transition:background .12s;white-space:nowrap;padding:0 8px}',
'.ytf-qa-b:hover{background:rgba(220,50,50,.6)}.ytf-qa-w:hover{background:rgba(255,193,7,.3)}',
'.ytf-qa-bar button+button{border-left:1px solid rgba(255,255,255,.18)}',
'#ytf-nw{position:fixed;top:70px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:2147483647;pointer-events:none}',
'.ytf-toast{display:flex;align-items:center;gap:8px;padding:9px 12px;background:var(--bg);border:1px solid var(--bd);border-radius:10px;font-size:13px;font-family:var(--font);color:var(--t);box-shadow:0 4px 14px rgba(0,0,0,.35);max-width:290px;pointer-events:all;opacity:0;transform:translateX(20px);transition:.25s}',
'.ytf-toast.ytf-ti{opacity:1;transform:translateX(0)}.ytf-tm{flex:1}.ytf-tx{background:none;border:none;cursor:pointer;color:var(--t2);font-size:11px;flex-shrink:0}',
// 頂欄注入按鈕
'#ytf-hb{background:none;border:none;cursor:pointer;font-size:18px;padding:6px 8px;border-radius:50%;color:var(--t);transition:.2s;flex-shrink:0}',
'#ytf-hb:hover{background:rgba(255,255,255,.1)}',
// 統計區塊 - 橫條圖設計
'.ytf-stat-sec{background:var(--bg2);border:1px solid var(--bd);border-radius:var(--r);padding:12px;display:flex;flex-direction:column;gap:6px}',
'.ytf-stat-sec-hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;font-weight:600;font-size:13px}',
'.ytf-stat-total{font-size:11px;color:var(--t2);font-weight:400}',
'.ytf-sr2{display:flex;flex-direction:column;padding:5px 0;border-bottom:1px solid var(--bd);font-size:12px;min-width:0;gap:4px}',
'.ytf-sr2:last-child{border-bottom:none}',
'.ytf-sr2-bar-wrap{grid-column:1/-1;height:4px;background:var(--bg3);border-radius:2px;overflow:hidden;margin-bottom:2px}',
'.ytf-sr2-bar{height:100%;border-radius:2px;transition:width .4s ease;min-width:2px}',
'.ytf-sr2-kw{background:var(--ac)}',
'.ytf-sr2-ch{background:#2196f3}',
'.ytf-sr2-row{display:flex;justify-content:space-between;align-items:center;gap:4px;min-width:0}',
'.ytf-sr2-label{color:var(--t);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}',
'.ytf-sr2-count{color:var(--ac);font-weight:700;flex-shrink:0;white-space:nowrap}',
// 關於頁面列表
'.ytf-pane ul li{font-size:13px;color:var(--t);line-height:1.6}',
// Regex 頻道條目 badge
'.ytf-re-badge{display:inline-flex;align-items:center;padding:1px 5px;background:rgba(100,149,237,.2);border:1px solid rgba(100,149,237,.5);border-radius:4px;font-size:10px;font-weight:700;color:#6495ed;font-family:monospace;margin-right:5px;flex-shrink:0}',
// Donate 區塊
'.ytf-donate-box{border-color:var(--gold)!important;background:rgba(255,193,7,.05)!important}',
'.ytf-donate-txt{font-size:13px;color:var(--t);line-height:1.6;margin:0 0 10px}',
'.ytf-donate-btn{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;background:#f6c915;color:#1a1a1a;border-radius:8px;font-weight:700;font-size:13px;text-decoration:none;transition:.15s}',
'.ytf-donate-btn:hover{filter:brightness(1.1);transform:translateY(-1px)}',
'.ytf-donate-row{display:flex;gap:8px;flex-wrap:wrap}',
'.ytf-kofi-btn{background:#29abe0;color:#fff}',
'.ytf-gh-link{color:var(--p);text-decoration:none;font-weight:600}',
'.ytf-gh-link:hover{text-decoration:underline}',
// 標籤篩選 bar(隱藏名單頁頂部)
'.ytf-tag-bar{display:flex;flex-wrap:wrap;gap:6px;padding:4px 0}',
'.ytf-tagf{padding:4px 10px;background:var(--bg3);border:1px solid var(--bd);border-radius:20px;color:var(--t2);font-size:11px;cursor:pointer;transition:.15s;white-space:nowrap}',
'.ytf-tagf:hover{border-color:var(--ac);color:var(--t)}',
'.ytf-tagf-on{background:var(--ac);border-color:var(--ac);color:#fff!important}',
// 頻道列表標籤區
// D-4: .ytf-ci-nametags 讓 name、sid、re-badge、tags chips 同在一個 flex-wrap 容器
'.ytf-ci-nametags{flex:1;min-width:0;display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}',
'.ytf-tags-area{display:inline-flex;align-items:center;gap:4px;flex-wrap:wrap}',
'.ytf-tag-chips{display:inline-flex;flex-wrap:wrap;gap:3px}',
'.ytf-tag-edit{padding:2px 6px;cursor:default;display:inline-flex;align-items:center;gap:3px}',
'.ytf-tag-rm{background:none;border:none;cursor:pointer;font-size:14px;color:var(--t2);padding:0 3px;line-height:1;margin-left:2px}.ytf-tag-rm:hover{color:var(--ac)}',
'.ytf-tag-toggle{background:none;border:1px dashed var(--bd);border-radius:50%;width:20px;height:20px;font-size:11px;cursor:pointer;color:var(--t2);display:flex;align-items:center;justify-content:center;flex-shrink:0;padding:0;transition:.15s}',
'.ytf-tag-toggle:hover{border-color:var(--ac);color:var(--ac)}',
// v1.1.0: 任務 1-3:標籤輸入 UI 重新設計(清楚分區 + 自動收起)
'.ytf-tag-input-row{display:flex;flex-direction:column;gap:10px;padding:10px 11px 12px;border-top:1px solid var(--bd);background:var(--bg2)}',
'.ytf-tag-section{display:flex;flex-direction:column;gap:6px}',
'.ytf-tag-section-label{font-size:11px;color:var(--t2);font-weight:600;letter-spacing:.3px}',
'.ytf-tag-input-flex{display:flex;gap:6px}',
'.ytf-tag-input-flex .ytf-in{flex:1}',
'.ytf-tag-add-btn{padding:6px 12px;background:var(--ac);border:none;border-radius:6px;color:#fff;font-size:12px;font-weight:600;cursor:pointer;flex-shrink:0;transition:.12s}',
'.ytf-tag-add-btn:hover{background:#d32f2f}',
'.ytf-tag-existing-chips{display:flex;flex-wrap:wrap;gap:4px;max-height:100px;overflow-y:auto;padding:4px 2px}',
'.ytf-tag-pick-chip{background:var(--bg3);border:1px solid var(--bd);border-radius:12px;padding:3px 9px;cursor:pointer;font-size:12px;color:var(--t);display:inline-flex;align-items:center;gap:3px;transition:.12s}',
'.ytf-tag-pick-chip:hover{background:var(--ac);border-color:var(--ac);color:#fff}',
'.ytf-tag-pick-chip small{color:var(--t2);font-size:10px}',
'.ytf-tag-pick-chip:hover small{color:#fff;opacity:.85}',
// 已擁有狀態:淡紅底表示這個 tag 該頻道已擁有(點擊可移除)
'.ytf-tag-pick-chip-on{background:rgba(255,68,68,.18);border-color:var(--ac);color:var(--ac)}',
'.ytf-tag-pick-chip-on:before{content:"✓";margin-right:2px;font-weight:bold}',
'.ytf-tag-pick-chip-on:hover{background:rgba(255,68,68,.35)}',
'.ytf-tag-pick-chip-on small{color:var(--ac);opacity:.7}',
'.ytf-tag-pick-empty{font-size:11px;color:var(--t2);padding:4px;font-style:italic}',
// ytf-ci-main layout fix for tags
'.ytf-ci-main{display:flex;align-items:center;gap:8px;padding:7px 11px;min-width:0}',
// D-4: first-child 的 flex:1 已移到 .ytf-ci-nametags
// 語言過濾相關
'.ytf-langs{display:flex;flex-wrap:wrap;gap:6px 10px}',
'.ytf-lbl-lang{font-size:12px;padding:3px 0}',
'.ytf-lang-ref{background:none;border:1px solid var(--bd);border-radius:6px;padding:6px 10px;color:var(--t2);font-size:12px;cursor:pointer;text-align:left;width:100%;transition:.15s}',
'.ytf-lang-ref:hover{border-color:var(--ac);color:var(--ac)}',
// 白名單獨享模式
'.ytf-wlo-warn{font-size:12px;color:var(--gold);margin-top:4px}',
// 編輯按鈕與 inline 編輯列
'.ytf-edit-btn{background:none;border:none;cursor:pointer;font-size:12px;padding:3px 5px;color:var(--t2);border-radius:4px;transition:.15s;flex-shrink:0}.ytf-edit-btn:hover{background:var(--bg3);color:var(--t)}',
'.ytf-ci-actions{display:flex;align-items:center;gap:2px;flex-shrink:0}',
'.ytf-edit-row{padding:10px 11px;border-top:1px solid var(--bd);background:var(--bg3)}',
'.ytf-edit-fields{display:flex;flex-direction:column;gap:8px}',
'.ytf-sort-btn{background:none;border:1px solid var(--bd);border-radius:4px;padding:2px 6px;font-size:12px;cursor:pointer;color:var(--t2);transition:.15s}.ytf-sort-btn:hover:not(:disabled){background:var(--bg3);border-color:var(--ac);color:var(--t)}.ytf-sort-btn:disabled{opacity:.3;cursor:default}',
// 關鍵字群組編輯
'.ytf-grp-edit-btn{background:none;border:1px solid var(--bd);border-radius:4px;padding:2px 6px;font-size:12px;cursor:pointer;transition:.15s}.ytf-grp-edit-btn:hover{background:var(--bg3);border-color:var(--ac)}',
'.ytf-grp-edit-row{padding:10px 0 4px;border-top:1px dashed var(--bd);margin-top:2px}',
'.ytf-grp-edit-fields{display:flex;flex-direction:column;gap:4px}',
].join(''));
// ============================================================
// QUICK ADD
// ============================================================
var QA = (function() {
var A='data-ytf-qa';
function injectCards(cards) {
Array.prototype.slice.call(cards).forEach(function(card){
ftCount('qaCalls');
if(card.getAttribute(A)){ ftCount('qaSkipped_alreadyMarked'); return; }
// v1.1.0: 起 Observer SEL 已避免父子重複選取,不再需要父層檢查
// 先取 info,若卡片 metadata 尚未渲染則不打標記,下次 poll 重試
// (不能先標記再 return,否則卡片會永遠失去注入機會)
var info=Filter.getInfo(card);
if(!info.channelName&&!info.channelId){
ftCount('qaSkipped_noInfo');
if(_dbgOn){
ftDbg('QA skip (no info):', card.tagName,
'children=', card.children.length,
'hasThumb=', !!card.querySelector('a[href*="watch?v="]'),
'hasChLink=', !!card.querySelector('a[href^="/@"]'),
'hasAvatar=', !!card.querySelector('.yt-spec-avatar-shape__button,.ytSpecAvatarShapeButton'));
}
return;
}
// 合輯(YouTube Mix / Radio):非單一頻道內容,不注入快捷按鈕
if(info.isCollection){
card.setAttribute(A,'1');
if(_dbgOn) ftDbg('QA skip collection:', (info.title||'').slice(0,40));
return;
}
// v1.1.0: 任務 5:已白名單頻道不注入快捷按鈕(使用者已信任這些頻道)
if(S.isWhitelisted(info.channelId, info.channelName)){
card.setAttribute(A,'1');
if(_dbgOn) ftDbg('QA skip whitelisted:', info.channelName);
return;
}
card.setAttribute(A,'1');
ftCount('qaInjected');
if(_dbgOn) ftDbg('QA inject:', info.channelName, '| tag=', card.tagName);
var chName = info.channelName||info.channelId||'';
var chTitle = t('qa.channelTip')+chName;
// 判斷是否為側邊推薦的 compact 卡片
// 新版 ViewModel:yt-lockup-view-model 下的 div 有 ytLockupViewModelHorizontal class
// 舊版:ytd-compact-video-renderer tag
var isCompact = card.tagName==='YTD-COMPACT-VIDEO-RENDERER' ||
card.classList.contains('ytd-compact-video-renderer') ||
card.tagName==='YT-LOCKUP-VIEW-MODEL' && (function(){
var inner = card.querySelector('.ytLockupViewModelHost');
return inner && inner.classList.contains('ytLockupViewModelHorizontal');
})();
// 找縮圖區塊(新舊版 class 都支援)
var thumbLink = card.querySelector(
'a.ytLockupViewModelContentImage,' +
'a.yt-lockup-view-model__content-image,' +
'#thumbnail,ytd-thumbnail'
);
function doBlock(ev) {
ev.preventDefault(); ev.stopPropagation();
var ok=S.addToBlocklist({id:info.channelId,name:info.channelName});
Filter.reset();
doFilter(Obs.cards());
// 若面板開著且在 blocklist/whitelist Tab,同步更新清單 DOM
Panel.refreshList('blocklist');
Notif.showForce(ok?t('qa.hiddenPrefix')+chName:t('qa.quote.open')+chName+t('qa.conflict.WLToBlock'));
}
function doWhite(ev) {
ev.preventDefault(); ev.stopPropagation();
var ok=S.addToWhitelist({id:info.channelId,name:info.channelName});
Filter.reset();
doFilter(Obs.cards());
// 若面板開著且在 blocklist/whitelist Tab,同步更新清單 DOM
Panel.refreshList('whitelist');
Notif.showForce(ok?t('qa.already.white')+chName:t('qa.quote.open')+chName+t('qa.conflict.blockToWL'));
}
if (isCompact) {
// ── Compact 模式(側邊推薦):小圓形圖示 ──
var bBtn=h('button',{cls:'ytf-qa-ic ytf-qa-ic-b',title:t('qa.blockTitle')+chName},['🚫']);
var wBtn=h('button',{cls:'ytf-qa-ic ytf-qa-ic-w',title:t('qa.whitelistTitle')+chName},['⭐']);
bBtn.addEventListener('click',doBlock);
wBtn.addEventListener('click',doWhite);
var icons=h('div',{cls:'ytf-qa-icons'},[bBtn,wBtn]);
// 掛在 thumbLink 的父元素(避免 a 元素的 overflow:hidden 裁切)
// 新版 ViewModel:thumbLink(a) 的父是 ytLockupViewModelHost div
var thumbParent = thumbLink ? thumbLink.parentElement : null;
var mountEl = thumbParent || card;
mountEl.style.position = 'relative';
// 如果 mountEl 是縮圖 a 本身(無父)也能用
if(thumbLink && mountEl === card) {
thumbLink.style.overflow = 'visible';
}
mountEl.appendChild(icons);
// hover card 時顯示圖示
card.addEventListener('mouseenter',function(){ icons.classList.add('ytf-qa-show'); });
card.addEventListener('mouseleave',function(){ icons.classList.remove('ytf-qa-show'); });
} else {
// ── Normal 模式(首頁/搜尋/訂閱):底部 bar ──
var bBtn2=h('button',{cls:'ytf-qa-b',title:t('qa.blockTitle')+chName},[t('qa.block')]);
var wBtn2=h('button',{cls:'ytf-qa-w',title:t('qa.whitelistTitle')+chName},[t('qa.whitelist')]);
bBtn2.addEventListener('click',doBlock);
wBtn2.addEventListener('click',doWhite);
var bar=h('div',{cls:'ytf-qa-bar'},[bBtn2,wBtn2]);
// bar 掛在 thumbLink 的直接父元素,而非 card
// 避免 card.position:relative 殘留在 SPA 複用的 DOM 上造成黑色空白
var mountEl = thumbLink ? (thumbLink.parentElement || card) : card;
mountEl.style.position='relative';
mountEl.appendChild(bar);
function positionBar() {
if(!thumbLink) return;
var rect=thumbLink.getBoundingClientRect();
var mountRect=mountEl.getBoundingClientRect();
// 縮圖 lazy load 未完成(rect.width=0),等待 img load 後重試一次
if(!rect.width) {
var img=thumbLink.querySelector('img');
if(img && !img._ytfPosListener) {
img._ytfPosListener=true;
img.addEventListener('load',function(){positionBar();},{once:true});
}
return;
}
bar.style.top=(rect.bottom-mountRect.top-36)+'px';
bar.style.left=(rect.left-mountRect.left)+'px';
bar.style.width=rect.width+'px';
bar.style.bottom='auto';
}
card.addEventListener('mouseenter',function(){ positionBar(); bar.classList.add('ytf-qa-show'); });
card.addEventListener('mouseleave',function(){ bar.classList.remove('ytf-qa-show'); });
}
});
}
function injectChannelPage() {
if(!location.pathname.startsWith('/@')&&!location.pathname.startsWith('/channel/')) return;
if(document.querySelector('.ytf-cpb')) return;
var header=document.querySelector('#channel-header-container,ytd-channel-header-renderer'); if(!header) return;
var nameEl=header.querySelector('#channel-name yt-formatted-string,#channel-name');
var cName=nameEl?nameEl.textContent.trim():'';
var cId=location.pathname.replace(/^\//,'');
var bBtn=h('button',{cls:'ytf-cbb'},[t('qa.block')]);
var wBtn=h('button',{cls:'ytf-cbw'},[t('qa.whitelist')]);
bBtn.addEventListener('click',function(){
var ok=S.addToBlocklist({id:cId,name:cName});
if(ok){
bBtn.textContent=t('qa.hidden');
bBtn.disabled=true;
bBtn.style.opacity='0.6';
}
Notif.showForce(ok?t('qa.already.block')+cName:t('qa.quote.open')+cName+t('qa.conflict.WLToBlock'),'success');
});
wBtn.addEventListener('click',function(){
var ok=S.addToWhitelist({id:cId,name:cName});
if(ok){
wBtn.textContent=t('qa.whitelisted');
wBtn.disabled=true;
wBtn.style.opacity='0.6';
}
Notif.showForce(ok?t('qa.already.white')+cName:t('qa.quote.open')+cName+t('qa.conflict.blockToWL'),'success');
});
var bar=h('div',{cls:'ytf-cpb'},[bBtn,wBtn]);
header.appendChild(bar);
}
return {injectCards,injectChannelPage};
})();
// ============================================================
// MAIN
// ============================================================
function doFilter(cards) {
var arr=Array.prototype.slice.call(cards); if(!arr.length) return;
if(_dbgOn) ftDbg('doFilter', arr.length, 'cards');
var hidden=Filter.processAll(arr); if(hidden.length) Notif.summary(hidden);
QA.injectCards(arr);
}
function init() {
Panel.build();
FAB.build(); // 只注入頂欄按鈕
Obs.start(doFilter);
Obs.watchNav(function(){
Filter.reset();
Obs.restart(doFilter);
Panel.build(); // 確保 SPA 導航後面板仍掛在 DOM 上
setTimeout(function(){
QA.injectChannelPage();
FAB.injectHeaderBtn();
},1200);
});
GM_registerMenuCommand(t('tm.togglePanel'),function(){Panel.show();});
GM_registerMenuCommand(t('tm.toggleFilter'),function(){S.setEnabled(!S.isEnabled());Filter.reset();FAB.update();if(S.isEnabled())doFilter(Obs.cards());});
GM_registerMenuCommand(t('tm.backupNow'),function(){IO.autoBackup();});
setTimeout(function(){QA.injectChannelPage();},1500);
// 偵測分享連結匯入
IO.checkImportFromHash();
// 自動備份檢查
setTimeout(function(){
var gs=S.getGlobal();
if(!gs.autoBackup) return;
var interval=(gs.backupInterval||7)*86400000;
if(!gs.lastBackup||Date.now()-gs.lastBackup>interval) IO.autoBackup();
}, 3000);
}
if (document.body) { setTimeout(init,600); }
else { document.addEventListener('DOMContentLoaded',function(){setTimeout(init,600);}); }
})();