您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
直播间内容记录 https://github.com/Xinrea/Vecorder
// ==UserScript== // @name Vecorder // @namespace https://www.joi-club.cn/ // @version 1.0.0 // @description 直播间内容记录 https://github.com/Xinrea/Vecorder // @author Xinrea // @license MIT // @match https://live.bilibili.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/moment.min.js // @run-at document-end // ==/UserScript== // IndexedDB 存储管理 class VecorderStorage { constructor() { this.dbName = "VecorderDB"; this.dbVersion = 1; this.storeName = "vecorder_data"; this.db = null; this.isInitialized = false; } // 初始化数据库 async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.dbVersion); request.onerror = () => { console.error("Vecorder: IndexedDB 打开失败:", request.error); reject(request.error); }; request.onsuccess = () => { this.db = request.result; this.isInitialized = true; console.log("Vecorder: IndexedDB 初始化成功"); resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; // 创建对象存储 if (!db.objectStoreNames.contains(this.storeName)) { const store = db.createObjectStore(this.storeName, { keyPath: "key", }); console.log("Vecorder: 创建 IndexedDB 存储"); } }; }); } // 获取数据 async get(key, defaultValue = null) { if (!this.isInitialized) { await this.init(); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], "readonly"); const store = transaction.objectStore(this.storeName); const request = store.get(key); request.onerror = () => { console.error("Vecorder: 获取数据失败:", request.error); reject(request.error); }; request.onsuccess = () => { const result = request.result; if (result) { resolve(result.value); } else { resolve(defaultValue); } }; }); } // 设置数据 async set(key, value) { if (!this.isInitialized) { await this.init(); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); const request = store.put({ key, value }); request.onerror = () => { console.error("Vecorder: 设置数据失败:", request.error); reject(request.error); }; request.onsuccess = () => { console.log("Vecorder: 数据保存成功:", key); resolve(); }; }); } // 删除数据 async delete(key) { if (!this.isInitialized) { await this.init(); } return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], "readwrite"); const store = transaction.objectStore(this.storeName); const request = store.delete(key); request.onerror = () => { console.error("Vecorder: 删除数据失败:", request.error); reject(request.error); }; request.onsuccess = () => { console.log("Vecorder: 数据删除成功:", key); resolve(); }; }); } // 获取存储空间信息 async getStorageInfo() { if (!this.isInitialized) { await this.init(); } return new Promise((resolve, reject) => { try { // 获取已用空间 const transaction = this.db.transaction([this.storeName], "readonly"); const store = transaction.objectStore(this.storeName); const request = store.getAll(); request.onerror = () => { console.error("Vecorder: 获取存储信息失败:", request.error); reject(request.error); }; request.onsuccess = () => { const data = request.result; let usedSize = 0; // 计算已用空间 data.forEach((item) => { usedSize += JSON.stringify(item).length; }); // 获取浏览器存储配额信息 if ("storage" in navigator && "estimate" in navigator.storage) { navigator.storage .estimate() .then((estimate) => { const totalSpace = estimate.quota || 0; const availableSpace = estimate.usage || 0; const remainingSpace = totalSpace - availableSpace; resolve({ usedSize: usedSize, totalSpace: totalSpace, availableSpace: availableSpace, remainingSpace: remainingSpace, dataCount: data.length, }); }) .catch((error) => { console.error("Vecorder: 获取存储配额失败:", error); resolve({ usedSize: usedSize, totalSpace: 0, availableSpace: 0, remainingSpace: 0, dataCount: data.length, }); }); } else { // 如果不支持 storage.estimate,只返回已用空间 resolve({ usedSize: usedSize, totalSpace: 0, availableSpace: 0, remainingSpace: 0, dataCount: data.length, }); } }; } catch (error) { console.error("Vecorder: 获取存储信息时出错:", error); reject(error); } }); } // 格式化字节大小 formatBytes(bytes) { if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; } // 检查是否有 GM 数据需要迁移 async checkAndMigrateData() { try { // 检查是否有 GM 数据 const gmData = GM_getValue(dbname, null); const gmOptions = GM_getValue("vop", null); if (gmData || gmOptions) { console.log("Vecorder: 检测到 GM 数据,开始迁移到 IndexedDB"); // 迁移主数据 if (gmData) { await this.set(dbname, gmData); GM_deleteValue(dbname); console.log("Vecorder: 主数据迁移完成"); } // 迁移选项数据 if (gmOptions) { await this.set("vop", gmOptions); GM_deleteValue("vop"); console.log("Vecorder: 选项数据迁移完成"); } console.log("Vecorder: 数据迁移完成,已清空 GM 存储"); return true; } return false; } catch (error) { console.error("Vecorder: 数据迁移失败:", error); return false; } } } // 创建存储实例 const vecorderStorage = new VecorderStorage(); // 全局更新存储信息函数 async function updateStorageInfo() { try { if (!window.vecorderUseGMStorage) { const storageInfo = await vecorderStorage.getStorageInfo(); let infoText = `已用: ${vecorderStorage.formatBytes( storageInfo.usedSize )} | 数据: ${storageInfo.dataCount}`; if (storageInfo.totalSpace > 0) { infoText += ` | 剩余: ${vecorderStorage.formatBytes( storageInfo.remainingSpace )}`; } $("#vecorder-storage-info").html(` <div class="storage-info-item"> <span class="storage-value">${infoText}</span> </div> `); } else { $("#vecorder-storage-info").html(` <div class="storage-info-item"> <span class="storage-value">GM 存储模式</span> </div> `); } } catch (error) { console.error("Vecorder: 更新存储信息失败:", error); $("#vecorder-storage-info").html(` <div class="storage-info-item"> <span class="storage-value">存储信息获取失败</span> </div> `); } } // 存储适配器,根据情况选择使用 IndexedDB 还是 GM 存储 const storageAdapter = { async set(key, value) { if (window.vecorderUseGMStorage) { GM_setValue(key, value); return Promise.resolve(); } else { return vecorderStorage.set(key, value); } }, async get(key, defaultValue = null) { if (window.vecorderUseGMStorage) { const value = GM_getValue(key, defaultValue); return Promise.resolve(value); } else { return vecorderStorage.get(key, defaultValue); } }, async delete(key) { if (window.vecorderUseGMStorage) { GM_deleteValue(key); return Promise.resolve(); } else { return vecorderStorage.delete(key); } }, }; // 立即执行的调试信息 console.log("Vecorder: 脚本开始加载"); console.log( "Vecorder: jQuery版本:", typeof $ !== "undefined" ? $.fn.jquery : "未加载" ); console.log( "Vecorder: Moment版本:", typeof moment !== "undefined" ? moment.version : "未加载" ); console.log("Vecorder: 当前页面:", window.location.href); vlog("脚本已加载,版本 0.70"); function vlog(msg) { console.log("[Vecorder]" + msg); } function p(msg) { return { time: new Date().getTime(), content: msg, }; } // 根据当前地址获取直播间ID function getRoomID() { let roomid = window.location.pathname.substring(1); return roomid; //获取当前房间号 } var dbname = "vdb" + getRoomID(); // 初始化数据存储 let db = []; let Option = { reltime: false, toffset: 0 }; // 异步初始化数据 async function initializeData() { try { // 检查并迁移数据 const migrated = await vecorderStorage.checkAndMigrateData(); // 从 IndexedDB 加载数据 const dbData = await vecorderStorage.get(dbname, "[]"); const optionsData = await vecorderStorage.get( "vop", '{"reltime":false,"toffset":0}' ); db = JSON.parse(dbData); Option = JSON.parse(optionsData); console.log("Vecorder: 数据初始化完成"); if (migrated) { console.log("Vecorder: 数据已从 GM 存储迁移到 IndexedDB"); } } catch (error) { console.error("Vecorder: IndexedDB 初始化失败,回退到 GM 存储:", error); // 如果 IndexedDB 失败,回退到 GM 存储 db = JSON.parse(GM_getValue(dbname, "[]")); Option = JSON.parse(GM_getValue("vop", '{"reltime":false,"toffset":0}')); // 标记使用 GM 存储模式 window.vecorderUseGMStorage = true; console.log("Vecorder: 已切换到 GM 存储模式"); } } // 数据初始化将在 DOM ready 时进行 function nindexOf(n) { for (let i in db) { if (!db[i].del && db[i].name == n) return i; } return -1; } function tindexOf(id, t) { for (let i in db[id].lives) { if (!db[id].lives[i].del && db[id].lives[i].title == t) return i; } return -1; } async function gc() { for (let i = db.length - 1; i >= 0; i--) { if (db[i].del) { db.splice(i, 1); continue; } for (let j = db[i].lives.length - 1; j >= 0; j--) { if (db[i].lives[j].del) { db[i].lives.splice(j, 1); continue; } } } await storageAdapter.set(dbname, JSON.stringify(db)); } async function addPoint(t, msg) { console.log("addPoint", t, msg); let ltime = t * 1000; if (ltime == 0) return; let [name, link, title] = getRoomInfo(); console.log("CurrentRoom:", name, link, title); let id = nindexOf(name); if (id == -1) { db.push({ name: name, link: link, del: false, lives: [ { title: title, time: ltime, del: false, points: [p(msg)], }, ], }); } else { let lid = tindexOf(id, title); if (lid == -1) { db[id].lives.push({ title: title, time: ltime, points: [p(msg)], }); } else { db[id].lives[lid].points.push(p(msg)); } } await storageAdapter.set(dbname, JSON.stringify(db)); $(`#vecorder-list`).replaceWith(dbToListview()); // 更新存储信息 if (toggle) { updateStorageInfo(); } } function getMsg(body) { var vars = body.split("&"); for (var i = 0; i < vars.length; i++) { var pair = vars[i].split("="); if (pair[0] == "msg") { return decodeURI(pair[1]); } } return false; } function getRoomInfo() { let resp = $.ajax({ url: "https://api.live.bilibili.com/xlive/web-room/v1/index/getH5InfoByRoom?room_id=" + getRoomID(), async: false, }).responseJSON.data; console.log("RoomInfo:", resp); return [ resp.anchor_info.base_info.uname, "https://space.bilibili.com/" + resp.room_info.uid, resp.room_info.title, ]; } function tryAddPoint(msg) { // https://api.live.bilibili.com/room/v1/Room/room_init?id={roomID} console.log(msg, getRoomID()); $.ajax({ url: "https://api.live.bilibili.com/room/v1/Room/room_init?id=" + getRoomID(), async: true, success: function (resp) { let t = 0; if (resp.data.live_status != 1) t = 0; else t = resp.data.live_time; addPoint(t, msg).catch((error) => { console.error("Vecorder: 添加时间点失败:", error); }); }, }); } let toggle = false; // 确保DOM加载完成后再执行 $(document).ready(async function () { console.log("Vecorder: DOM已加载完成"); vlog("开始初始化Vecorder功能"); // 等待数据初始化完成 try { await initializeData(); console.log("Vecorder: 数据初始化完成,开始检查页面元素"); } catch (error) { console.error("Vecorder: 数据初始化失败:", error); } // 延迟检查页面元素 setTimeout(function () { console.log("Vecorder: 延迟检查页面元素"); console.log( "Vecorder: control-panel-ctnr-box 存在:", $("#control-panel-ctnr-box").length > 0 ); console.log( "Vecorder: bottom-actions 存在:", $(".bottom-actions").length > 0 ); console.log( "Vecorder: bottom-actions 存在:", $(".bottom-actions").length > 0 ); }, 2000); }); // 尝试多个可能的选择器来插入时间点输入框 function insertTimeInput() { const selectors = [ "#control-panel-ctnr-box > div.bottom-actions.p-relative", "#control-panel-ctnr-box .bottom-actions", ".bottom-actions", "#control-panel-ctnr-box", ]; for (let selector of selectors) { if ($(selector).length > 0) { console.log(`Vecorder: 使用选择器 ${selector} 插入时间点输入框`); // 创建时间点输入框容器 const inputContainer = $( `<div id="vecorder-input-container"> <div id="point-input"> <textarea placeholder="输入内容并回车添加时间点"></textarea> </div> </div>` ); inputContainer .find("#point-input > textarea") .bind("keypress", function (event) { if (event.keyCode == "13") { window.event.returnValue = false; console.log("Enter detected"); tryAddPoint($("#point-input > textarea").val()); $("#point-input > textarea").val(""); } }); // 尝试插入到不同的位置 const targetSelectors = [ "#control-panel-ctnr-box > div.bottom-actions.p-relative", ".bottom-actions", "#control-panel-ctnr-box", ]; for (let targetSelector of targetSelectors) { if ($(targetSelector).length > 0) { $(targetSelector).append(inputContainer); console.log(`Vecorder: 时间点输入框已插入到 ${targetSelector}`); // 在同一个元素上插入记录按钮和面板 insertRecordButton(); return true; // 返回true表示成功插入,停止检查 } } } } return false; // 如果没有找到合适的元素,返回false } console.log("Vecorder: 开始等待聊天输入区域元素"); waitForKeyElements( "#control-panel-ctnr-box > div.bottom-actions.p-relative", insertTimeInput, true // 只执行一次 ); // 尝试多个可能的选择器来插入记录按钮和面板 function insertRecordButton() { console.log("Vecorder: 开始插入记录按钮和面板"); // 直接使用当前找到的元素 const n = $("#control-panel-ctnr-box > div.bottom-actions.p-relative"); if (n.length > 0) { console.log(`Vecorder: 使用当前元素插入记录按钮和面板`); // 尝试调整现有按钮的样式 try { // 这里可以添加对现有按钮的调整,如果需要的话 console.log("Vecorder: 找到目标元素,准备插入记录按钮"); } catch (e) { console.log("Vecorder: 调整按钮样式时出错,继续执行", e); } // 这里可以添加其他初始化代码,如果需要的话 // create panel let panel = $( '<div id="vPanel"><p class="vecorder-title">🍊 直播笔记</p></div>' ); // 添加存储空间信息区域 let storageInfo = $( '<div id="vecorder-storage-info" class="vecorder-storage-info"></div>' ); panel.append(storageInfo); let contentList = dbToListview(); panel.append(contentList); // 创建底部操作区域 let bottomActions = $('<div class="vecorder-bottom-actions"></div>'); // 创建设置按钮和折叠面板 let settingsBtn = $( '<button class="vecorder-settings-btn" title="导出设置">⚙️</button>' ); let settingsPanel = $(`<div class="vecorder-settings-panel" style="display: none;"> <div class="timeop-item"> <input type="checkbox" id="reltime" value="false"/> <label for="reltime">按相对时间导出</label> </div> <div class="timeop-item"> <label for="toffset">时间偏移(秒):</label> <input type="number" id="toffset" value="${Option.toffset}"/> </div> </div>`); settingsBtn.click(function () { console.log("Vecorder: 设置按钮被点击"); settingsPanel.slideToggle(200); console.log("Vecorder: 设置面板可见性:", settingsPanel.is(":visible")); }); // 创建清空按钮 let clearBtn = $( '<button class="vecorder-clear-btn" title="清空所有数据">🗑️</button>' ); clearBtn.click(function () { if (confirm("确定要清空所有直播笔记吗?此操作不可恢复。")) { db = []; storageAdapter.delete(dbname).catch((error) => { console.error("Vecorder: 清空数据失败:", error); }); // 重新生成列表并更新显示 $(`#vecorder-list`).replaceWith(dbToListview()); // 更新存储信息 updateStorageInfo(); } }); // 添加按钮到底部操作区域 bottomActions.append(settingsBtn); bottomActions.append(clearBtn); panel.append(bottomActions); // 将设置面板添加到主面板中 panel.append(settingsPanel); let closeBtn = $('<a class="vecorder-close-btn">×</a>'); closeBtn.click(function () { console.log("Close clicked"); $("#vPanel").hide(); gc().catch((error) => { console.error("Vecorder: 保存数据失败:", error); }); toggle = false; recordBtn.removeClass("vecorder-record-btn-active"); // 更新存储信息 updateStorageInfo(); }); panel.append(closeBtn); // Setup recordButton let recordBtn = $('<div><span class="txt">记录</span></div>'); recordBtn.addClass("vecorder-record-btn"); // 将面板插入到body中,确保正确的定位 $("body").append(panel); $("#vPanel").hide(); console.log("Vecorder: 面板已插入到body中"); recordBtn.hover( function () { if (!toggle) $(this).addClass("vecorder-record-btn-hover"); }, function () { if (!toggle) $(this).removeClass("vecorder-record-btn-hover"); } ); recordBtn.click(function () { if (toggle) { $("#vPanel").hide(); gc(); toggle = false; $(this).removeClass("vecorder-record-btn-active"); return; } console.log("Toggle panel"); $("#vPanel").show(); // 更新存储信息 updateStorageInfo(); // 确保面板在正确的位置显示 if (Option.reltime) { $("#reltime").attr("checked", true); } // 绑定设置面板的事件 $("#reltime").change(function () { Option.reltime = $(this).prop("checked"); storageAdapter.set("vop", JSON.stringify(Option)).catch((error) => { console.error("Vecorder: 保存选项失败:", error); }); }); $("#toffset").change(function () { Option.toffset = $(this).val(); storageAdapter.set("vop", JSON.stringify(Option)).catch((error) => { console.error("Vecorder: 保存选项失败:", error); }); }); $(this).addClass("vecorder-record-btn-active"); toggle = true; }); // 将记录按钮插入到输入容器中,而不是直接插入到控制面板 $("#vecorder-input-container").append(recordBtn); console.log("Vecorder: 记录按钮已插入到输入容器中"); let styles = $(`<style type="text/css"></style>`); styles.text(` /* 主面板样式 */ #vPanel { line-height: 1.4; font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; display: block; box-sizing: border-box; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); padding: 12px; position: fixed; right: 20px; bottom: 120px; z-index: 99999; min-width: 320px; max-width: 420px; max-height: 75vh; overflow: hidden; display: flex; flex-direction: column; } /* 列表容器样式 */ .vecorder-list-container { max-height: 60vh; overflow-y: auto; padding: 0; margin: 0; } /* 空状态样式 */ .vecorder-empty-state { text-align: center; padding: 24px 16px; color: #6b7280; } .vecorder-empty-icon { margin-bottom: 12px; opacity: 0.6; display: flex; justify-content: center; } .vecorder-empty-icon svg { width: 36px; height: 36px; stroke: currentColor; stroke-width: 1.5; fill: none; } .vecorder-empty-text { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: #374151; } .vecorder-empty-hint { font-size: 11px; color: #9ca3af; } /* 主播分组样式 */ .vecorder-anchor-group { margin-bottom: 12px; border: 1px solid #e5e7eb; border-radius: 4px; overflow: hidden; background: #ffffff; } .vecorder-anchor-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); border-bottom: 1px solid #e5e7eb; } .vecorder-anchor-name { font-size: 14px; font-weight: 600; color: #1f2937; } .vecorder-anchor-count { font-size: 11px; color: #6b7280; background: rgba(35, 173, 229, 0.1); padding: 2px 8px; border-radius: 12px; } /* 直播列表样式 */ .vecorder-lives-list { max-height: 250px; overflow-y: auto; } .vecorder-live-item { border-bottom: 1px solid #f3f4f6; transition: all 0.2s ease; } .vecorder-live-item:last-child { border-bottom: none; } .vecorder-live-item:hover { background: #f9fafb; } .vecorder-live-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; min-height: 40px; } .vecorder-live-info { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; } .vecorder-live-date { font-size: 10px; color: #6b7280; font-weight: 500; background: rgba(35, 173, 229, 0.1); padding: 1px 4px; border-radius: 3px; display: inline-block; width: fit-content; } .vecorder-live-title { font-size: 12px; font-weight: 600; color: #1f2937; line-height: 1.3; word-break: break-word; white-space: normal; margin: 1px 0; } .vecorder-live-points-count { font-size: 10px; color: #9ca3af; font-weight: 500; } /* 操作按钮样式 */ .vecorder-live-actions { display: flex; gap: 2px; margin-left: 8px; } .vecorder-action-btn { width: 24px; height: 24px; border: none; border-radius: 4px; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; color: #6b7280; padding: 0; } .vecorder-action-btn svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; fill: none; } .vecorder-action-btn:hover { background: rgba(35, 173, 229, 0.1); color: #23ade5; transform: translateY(-1px); } .vecorder-delete-btn:hover { background: rgba(239, 68, 68, 0.1); color: #ef4444; } /* 滚动条样式 */ .vecorder-lives-list::-webkit-scrollbar { width: 4px; } .vecorder-lives-list::-webkit-scrollbar-track { background: transparent; } .vecorder-lives-list::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.1); border-radius: 2px; } .vecorder-lives-list::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.2); } /* 标题样式 */ .vecorder-title { font-size: 16px; font-weight: 600; margin: 0 0 12px 0; color: #1f2937; text-align: center; } /* 链接样式 */ .vName { color: #23ade5; cursor: pointer; text-decoration: none; transition: all 0.2s ease; font-weight: 500; } .vName:hover { color: #1a8bb8; text-decoration: underline; } /* 按钮样式 */ .vecorder-btn { font-family: inherit; font-size: 11px; font-weight: 500; padding: 6px 12px; border: none; border-radius: 4px; background: #23ade5; color: white; cursor: pointer; margin-left: 6px; display: inline-flex; align-items: center; justify-content: center; min-height: 28px; } .vecorder-btn:hover { background: #1a8bb8; } .vecorder-btn-danger { background: #ef4444; } .vecorder-btn-danger:hover { background: #dc2626; } .vecorder-btn-hover { background: linear-gradient(135deg, #1a8bb8 0%, #147a9e 100%); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(35, 173, 229, 0.4); } /* 记录按钮样式 */ .vecorder-record-btn { font-family: inherit; display: flex; align-items: center; justify-content: center; position: relative; box-sizing: border-box; padding: 4px 10px; cursor: pointer; outline: none; overflow: hidden; background: linear-gradient(135deg, #23ade5 0%, #1a8bb8 100%); color: #fff; border-radius: 12px; min-width: 40px; height: 24px; font-size: 11px; font-weight: 500; transition: all 0.2s ease; box-shadow: 0 2px 4px rgba(35, 173, 229, 0.3); border: none; flex-shrink: 0; } .vecorder-record-btn:hover { background: linear-gradient(135deg, #1a8bb8 0%, #147a9e 100%); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(35, 173, 229, 0.4); } .vecorder-record-btn:active { transform: translateY(0); box-shadow: 0 2px 4px rgba(35, 173, 229, 0.3); } .vecorder-record-btn-hover { background: linear-gradient(135deg, #1a8bb8 0%, #147a9e 100%); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(35, 173, 229, 0.4); } .vecorder-record-btn-active { background: linear-gradient(135deg, #0d749e 0%, #0a5a7a 100%); box-shadow: 0 4px 8px rgba(13, 116, 158, 0.4); } /* 关闭按钮 */ .vecorder-close-btn { position: absolute !important; right: 12px !important; top: 12px !important; font-size: 18px !important; color: #6b7280 !important; cursor: pointer; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border-radius: 50%; text-decoration: none; font-weight: 300; } .vecorder-close-btn:hover { color: #ef4444 !important; } /* 分割线 */ .vecorder-divider { border: 0; height: 1px; background: linear-gradient(90deg, transparent, #e5e7eb, transparent); margin: 12px 0; } /* 存储信息区域样式 */ .vecorder-storage-info { background: rgba(35, 173, 229, 0.05); border: 1px solid rgba(35, 173, 229, 0.2); border-radius: 4px; padding: 6px 10px; margin: 6px 0; font-size: 10px; } .storage-info-item { display: flex; justify-content: center; align-items: center; margin: 0; } .storage-value { color: #23ade5; font-weight: 600; font-family: 'Courier New', monospace; text-align: center; line-height: 1.2; } /* 底部操作区域 */ .vecorder-bottom-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 0; border-top: 1px solid #e5e7eb; margin-top: 8px; } /* 设置按钮 */ .vecorder-settings-btn { width: 28px; height: 28px; border: none; border-radius: 4px; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; color: #6b7280; font-size: 14px; padding: 0; } .vecorder-settings-btn:hover { background: rgba(35, 173, 229, 0.1); color: #23ade5; transform: translateY(-1px); } /* 清空按钮 */ .vecorder-clear-btn { width: 28px; height: 28px; border: none; border-radius: 4px; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; color: #6b7280; font-size: 14px; padding: 0; } .vecorder-clear-btn:hover { background: rgba(239, 68, 68, 0.1); color: #ef4444; transform: translateY(-1px); } /* 设置面板 */ .vecorder-settings-panel { background: rgba(35, 173, 229, 0.05); border: 1px solid rgba(35, 173, 229, 0.2); border-radius: 4px; padding: 12px; margin-top: 8px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 12px; overflow: hidden; } /* 时间选项区域 */ .timeop-item { margin: 8px 0; display: flex; align-items: center; gap: 8px; } .timeop-item label { font-size: 12px; color: #374151; font-weight: 500; flex: 1; } .vecorder-settings-panel input[type="number"] { width: 80px; padding: 6px 10px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 12px; outline: none; transition: all 0.2s ease; background: rgba(255, 255, 255, 0.9); } .vecorder-settings-panel input[type="number"]:focus { border-color: #23ade5; box-shadow: 0 0 0 3px rgba(35, 173, 229, 0.1); background: white; } .vecorder-settings-panel input[type="checkbox"] { accent-color: #23ade5; transform: scale(1.2); margin: 0; } .vecorder-settings-panel .timeop-item { margin: 6px 0; } .vecorder-settings-panel .timeop-item label { font-size: 11px; color: #374151; font-weight: 500; } /* 聊天输入框样式 */ #control-panel-ctnr-box > div.chat-input-ctnr-new.p-relative > div.medal-section { height: 30px; line-height: 13px; } /* Vecorder输入容器 */ #vecorder-input-container { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 6px 10px; background: rgba(255, 255, 255, 0.95); border-radius: 6px; border: 1px solid rgba(0, 0, 0, 0.08); transition: all 0.2s ease; } #vecorder-input-container:focus-within { border-color: #23ade5; box-shadow: 0 0 0 3px rgba(35, 173, 229, 0.1); } /* 时间点输入框 */ #point-input { flex: 1; min-width: 0; } #point-input > textarea { width: 100%; border: 0; outline: 0; resize: none; background: transparent; color: #374151; font-size: 12px; height: 20px; font-family: inherit; line-height: 1.4; } #point-input > textarea::placeholder { color: #9ca3af; } /* 响应式设计 */ @media (max-width: 768px) { #vPanel { right: 4px; left: 4px; bottom: 120px; min-width: auto; max-width: none; max-height: 80vh; } .vecorder-lives-list { max-height: 200px; } } /* 滚动条样式 */ #vPanel::-webkit-scrollbar { width: 6px; } #vPanel::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.05); border-radius: 3px; } #vPanel::-webkit-scrollbar-thumb { background: rgba(35, 173, 229, 0.3); border-radius: 3px; } #vPanel::-webkit-scrollbar-thumb:hover { background: rgba(35, 173, 229, 0.5); } /* 移除聊天控制面板的高度限制 */ #chat-control-panel-vm { max-height: none !important; height: auto !important; } /* 优化bottom-actions区域的布局 */ .bottom-actions { display: flex; flex-direction: column; gap: 8px; position: relative; } /* 调整发送按钮位置,避免覆盖输入框 */ .bottom-actions .right-action { position: static !important; align-self: flex-end; margin-top: 4px; } /* 确保Vecorder输入容器不被覆盖 */ #vecorder-input-container { position: relative; z-index: 1; } `); $("head").prepend(styles); return true; // 成功插入后退出函数 } console.log("Vecorder: 未找到合适的控制面板元素"); return false; } // 移除单独的insertRecordButton调用,因为现在在insertTimeInput中调用 function dbToListview() { let urlObject = window.URL || window.webkitURL || window; let content = $( `<div id="vecorder-list" class="vecorder-list-container"></div>` ); // 如果没有数据,显示空状态 if (db.length === 0 || db.every((item) => item.del)) { content.append(` <div class="vecorder-empty-state"> <div class="vecorder-empty-icon"> <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> <polyline points="14,2 14,8 20,8"/> <line x1="16" y1="13" x2="8" y2="13"/> <line x1="16" y1="17" x2="8" y2="17"/> <polyline points="10,9 9,9 8,9"/> </svg> </div> <div class="vecorder-empty-text">暂无直播笔记</div> <div class="vecorder-empty-hint">在下方输入框添加时间点即可开始记录</div> </div> `); return content; } // 创建主播分组列表 for (let i in db) { if (db[i].del) continue; // 检查该主播是否有未删除的直播 let hasValidLives = false; for (let j in db[i].lives) { if (!db[i].lives[j].del) { hasValidLives = true; break; } } if (!hasValidLives) continue; // 创建主播分组 let anchorGroup = $(` <div class="vecorder-anchor-group"> <div class="vecorder-anchor-header"> <span class="vecorder-anchor-name">${db[i].name}</span> <span class="vecorder-anchor-count">${ db[i].lives.filter((live) => !live.del).length } 场直播</span> </div> <div class="vecorder-lives-list"></div> </div> `); let livesList = anchorGroup.find(".vecorder-lives-list"); // 按时间倒序排列直播 let sortedLives = db[i].lives .filter((live) => !live.del) .sort((a, b) => b.time - a.time); for (let live of sortedLives) { let liveItem = $(` <div class="vecorder-live-item"> <div class="vecorder-live-header"> <div class="vecorder-live-info"> <span class="vecorder-live-date">${moment(live.time).format( "MM/DD HH:mm" )}</span> <span class="vecorder-live-title">${live.title}</span> <span class="vecorder-live-points-count">${ live.points.length } 个时间点</span> </div> <div class="vecorder-live-actions"> <button class="vecorder-action-btn vecorder-export-btn" title="导出笔记"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <polyline points="7,10 12,15 17,10"/> <line x1="12" y1="15" x2="12" y2="3"/> </svg> </button> <button class="vecorder-action-btn vecorder-delete-btn" title="删除"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <polyline points="3,6 5,6 21,6"/> <path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"/> <line x1="10" y1="11" x2="10" y2="17"/> <line x1="14" y1="11" x2="14" y2="17"/> </svg> </button> </div> </div> </div> `); // 绑定导出事件 liveItem.find(".vecorder-export-btn").click(function () { exportRaw( live, db[i].name, `[${db[i].name}][${live.title}][${moment(live.time).format( "YYYY-MM-DD" )}].txt` ); }); // 绑定删除事件 liveItem.find(".vecorder-delete-btn").click(function () { if (confirm(`确定要删除 "${live.title}" 的直播笔记吗?`)) { if (db[i].lives.length == 1) { db[i].del = true; anchorGroup.remove(); } else { live.del = true; liveItem.remove(); } storageAdapter.set(dbname, JSON.stringify(db)).catch((error) => { console.error("Vecorder: 保存数据失败:", error); }); // 更新存储信息 updateStorageInfo(); // 如果该主播组没有更多直播,移除整个组 if (anchorGroup.find(".vecorder-live-item").length === 0) { anchorGroup.remove(); } // 检查是否所有数据都被删除了,如果是则重新生成列表显示空状态 let hasValidData = false; for (let j in db) { if (!db[j].del) { for (let k in db[j].lives) { if (!db[j].lives[k].del) { hasValidData = true; break; } } if (hasValidData) break; } } if (!hasValidData) { // 重新生成列表显示空状态 $(`#vecorder-list`).replaceWith(dbToListview()); } } }); livesList.append(liveItem); } content.append(anchorGroup); } return content; } function exportRaw(live, v, fname) { var urlObject = window.URL || window.webkitURL || window; var export_blob = new Blob([rawToString(live, v)]); var save_link = document.createElementNS("http://www.w3.org/1999/xhtml", "a"); save_link.href = urlObject.createObjectURL(export_blob); save_link.download = fname; save_link.click(); } function rawToString(live, v) { let r = "# 由Vecorder自动生成,不妨关注下可爱的@轴伊Joi_Channel:https://space.bilibili.com/61639371/\n"; r += `# ${v} \n`; r += `# ${live.title} - 直播开始时间:${moment(live.time).format( "YYYY-MM-DD HH:mm:ss" )}\n\n`; for (let i in live.points) { if (!Option.reltime) r += `[${moment(live.points[i].time) .add(Option.toffset, "seconds") .format("HH:mm:ss")}] ${live.points[i].content}\n`; else { let seconds = moment(live.points[i].time).diff(moment(live.time), "second") + Number(Option.toffset); let minutes = Math.floor(seconds / 60); let hours = Math.floor(minutes / 60); seconds = seconds % 60; minutes = minutes % 60; r += `[${f(hours)}:${f(minutes)}:${f(seconds)}] ${ live.points[i].content }\n`; } } return r; } function f(num) { if (String(num).length > 2) return num; return (Array(2).join(0) + num).slice(-2); } function waitForKeyElements( selectorTxt /* Required: The jQuery selector string that specifies the desired element(s). */, actionFunction /* Required: The code to run when elements are found. It is passed a jNode to the matched element. */, bWaitOnce /* Optional: If false, will continue to scan for new elements even after the first match is found. */, iframeSelector /* Optional: If set, identifies the iframe to search. */ ) { console.log(`Vecorder: waitForKeyElements 检查选择器: ${selectorTxt}`); var targetNodes, btargetsFound; if (typeof iframeSelector == "undefined") targetNodes = $(selectorTxt); else targetNodes = $(iframeSelector).contents().find(selectorTxt); if (targetNodes && targetNodes.length > 0) { console.log(`Vecorder: 找到 ${targetNodes.length} 个匹配元素`); btargetsFound = true; /*--- Found target node(s). Go through each and act if they are new. */ targetNodes.each(function () { var jThis = $(this); var alreadyFound = jThis.data("alreadyFound") || false; if (!alreadyFound) { console.log(`Vecorder: 执行动作函数`); //--- Call the payload function. var cancelFound = actionFunction(jThis); if (cancelFound) { console.log(`Vecorder: 动作函数返回true,标记为已处理并停止检查`); jThis.data("alreadyFound", true); btargetsFound = true; // 保持为true,这样会清除定时器 } else { jThis.data("alreadyFound", true); console.log(`Vecorder: 元素已标记为已处理`); } } else { console.log(`Vecorder: 元素已经处理过,跳过`); } }); } else { console.log(`Vecorder: 未找到匹配元素,继续等待`); btargetsFound = false; } //--- Get the timer-control variable for this selector. var controlObj = waitForKeyElements.controlObj || {}; var controlKey = selectorTxt.replace(/[^\w]/g, "_"); var timeControl = controlObj[controlKey]; //--- Now set or clear the timer as appropriate. if (btargetsFound && bWaitOnce && timeControl) { //--- The only condition where we need to clear the timer. console.log(`Vecorder: 清除定时器,停止检查`); clearInterval(timeControl); delete controlObj[controlKey]; } else if (!btargetsFound) { //--- Set a timer, if needed. if (!timeControl) { console.log(`Vecorder: 设置定时器,继续检查`); timeControl = setInterval(function () { waitForKeyElements( selectorTxt, actionFunction, bWaitOnce, iframeSelector ); }, 300); controlObj[controlKey] = timeControl; } } waitForKeyElements.controlObj = controlObj; }