// ==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;
}