// ==UserScript==
// @name 海豚知道学习平台 · 视频增强(快捷键 / 进度条标记 / 收藏笔记)
// @namespace https://learn.htknow.com/
// @version 1.1.0
// @description 为 learn.htknow.com 课程视频增加:空格播放暂停、F 全屏、进度条标记点、时间点收藏与笔记、倍速记忆。数据保存在浏览器 localStorage,纯本地不上传。
// @author dogkun
// @match https://learn.htknow.com/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
var CONFIG = {
debug: false,
keys: {
playPause: ['Space'],
fullscreen: ['KeyF'],
addMarker: ['KeyM'],
addNote: ['KeyB'],
togglePanel: ['KeyC'],
seekBack: ['ArrowLeft'],
seekFwd: ['ArrowRight'],
volumeUp: ['ArrowUp'],
volumeDown: ['ArrowDown'],
speedDown: ['BracketLeft'],
speedUp: ['BracketRight'],
speedReset: ['Backslash']
},
seekStep: 5,
volumeStep: 0.1,
speedPresets: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3],
rememberSpeed: true,
jumpAutoplay: false,
autoFocusNewMarker: false,
autoFocusNewNote: true,
stripHeight: 12,
stripOffsetBottom: 0,
autoAvoidControlBar: true,
geomHeartbeatMs: 400,
markerColor: '#ffcc33',
markerSymbol: '?',
noteColor: '#ffcc33',
noteSymbol: '★',
stretchVideoInFullscreen: true,
fullscreenObjectFit: 'contain',
storagePrefix: { markers: 'htknow_markers_', notes: 'htknow_notes_' },
prefsKey: 'htknow_enh_prefs_v1'
};
var VERSION = '1.0.0';
var GUARDED_ACTIONS = { seekBack: 1, seekFwd: 1, volumeUp: 1, volumeDown: 1 };
var Util = {
fmtTime: function (sec) {
var s = Number(sec);
if (!isFinite(s) || s < 0) s = 0;
s = Math.floor(s);
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
var ss = s % 60;
var pad = function (n) { return n < 10 ? '0' + n : String(n); };
return h > 0 ? h + ':' + pad(m) + ':' + pad(ss) : pad(m) + ':' + pad(ss);
},
clamp: function (v, lo, hi) {
v = Number(v);
if (!isFinite(v)) return lo;
return v < lo ? lo : (v > hi ? hi : v);
},
pctOf: function (t, duration) {
var d = Number(duration);
if (!isFinite(d) || d <= 0) return 0;
return Util.clamp((Number(t) || 0) / d * 100, 0, 100);
},
esc: function (s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
},
hashStr: function (s) {
var str = String(s), h = 5381;
for (var i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) >>> 0;
return h.toString(36);
},
createId: function () {
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
},
sanitizeKey: function (v) {
return String(v == null ? '' : v).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 60);
},
sortByTime: function (list) {
return (Array.isArray(list) ? list.slice() : []).sort(function (a, b) {
return (Number(a.t) || 0) - (Number(b.t) || 0);
});
},
findItem: function (list, id) {
if (!Array.isArray(list)) return null;
for (var i = 0; i < list.length; i++) if (list[i].id === id) return list[i];
return null;
},
addItem: function (list, item) {
return Util.sortByTime((Array.isArray(list) ? list.slice() : []).concat([item]));
},
removeItem: function (list, id) {
return (Array.isArray(list) ? list : []).filter(function (x) { return x.id !== id; });
},
updateItem: function (list, id, patch) {
return (Array.isArray(list) ? list : []).map(function (x) {
if (x.id !== id) return x;
var next = {};
for (var k in x) if (Object.prototype.hasOwnProperty.call(x, k)) next[k] = x[k];
for (var j in patch) if (Object.prototype.hasOwnProperty.call(patch, j)) next[j] = patch[j];
return next;
});
},
mergeByKey: function (existing, incoming, keyOf) {
var out = (Array.isArray(existing) ? existing : []).slice();
var seen = {};
out.forEach(function (x) { seen[keyOf(x)] = 1; });
var added = 0;
(Array.isArray(incoming) ? incoming : []).forEach(function (x) {
var k = keyOf(x);
if (seen[k]) return;
seen[k] = 1;
out.push(x);
added++;
});
return { list: out, added: added };
},
fillEmptyText: function (existing, incoming, field) {
var out = (Array.isArray(existing) ? existing : []).slice();
var filled = 0;
(Array.isArray(incoming) ? incoming : []).forEach(function (inc) {
var raw = inc ? inc[field] : '';
var incText = String(raw == null ? '' : raw).trim();
if (!incText) return;
for (var i = 0; i < out.length; i++) {
var cur = out[i];
if (Math.round(Number(cur.t) * 10) !== Math.round(Number(inc.t) * 10)) continue;
var curText = String(cur[field] == null ? '' : cur[field]).trim();
if (curText) continue;
var patch = {};
patch[field] = inc[field];
out[i] = Util.updateItem([cur], cur.id, patch)[0];
filled++;
break;
}
});
return { list: out, filled: filled };
},
normalizeMarker: function (m) {
m = m || {};
return {
id: String(m.id || Util.createId()),
t: Number(m.t) || 0,
label: String(m.label == null ? '' : m.label),
createdAt: Number(m.createdAt) || Date.now()
};
},
normalizeNote: function (n) {
n = n || {};
var created = Number(n.createdAt) || Date.now();
return {
id: String(n.id || Util.createId()),
t: Number(n.t) || 0,
text: String(n.text == null ? '' : n.text),
createdAt: created,
updatedAt: Number(n.updatedAt) || created
};
},
isTypingTarget: function (el) {
if (!el || !el.tagName) return false;
var tag = String(el.tagName).toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (el.isContentEditable === true) return true;
return false;
},
videoKeyFrom: function (loc, video) {
var KEY_PARAMS = ['lessonId', 'lesson_id', 'lessonid', 'videoId', 'video_id', 'videoid',
'courseId', 'course_id', 'courseid', 'chapterId', 'chapter_id', 'chapterid',
'sectionId', 'catalogId', 'id', 'cid', 'vid', 'lid'];
var parts = [];
var search = (loc && loc.search) || '';
var hash = (loc && loc.hash) || '';
try {
var sp = new URLSearchParams(search);
for (var i = 0; i < KEY_PARAMS.length; i++) {
var n = KEY_PARAMS[i];
var v = sp.get(n);
if (v != null && v !== '') parts.push(n.toLowerCase() + '-' + Util.sanitizeKey(v));
}
} catch (e) { }
if (hash) {
var qi = hash.indexOf('?');
if (qi >= 0) {
try {
var hp = new URLSearchParams(hash.slice(qi));
for (var j = 0; j < KEY_PARAMS.length; j++) {
var hn = KEY_PARAMS[j];
var hv = hp.get(hn);
if (hv != null && hv !== '') parts.push('h' + hn.toLowerCase() + '-' + Util.sanitizeKey(hv));
}
} catch (e2) { }
} else {
var segs = hash.split(/[\/?#]+/).filter(Boolean);
if (segs.length) parts.push('hseg-' + Util.sanitizeKey(segs.slice(-2).join('-')));
}
}
if (parts.length) return { key: parts.join('_').slice(0, 180), mode: 'params' };
var path = ((loc && loc.pathname) || '/').replace(/\/+$/, '') || '/';
return { key: 'path-' + Util.hashStr(path), mode: 'path' };
},
parseImport: function (text) {
var data = JSON.parse(text);
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('不是有效的 JSON 对象');
return {
markers: Array.isArray(data.markers) ? data.markers : [],
notes: Array.isArray(data.notes) ? data.notes : []
};
}
};
function createStore(storage, prefix) {
var P = prefix || CONFIG.storagePrefix;
function keyOf(kind, videoId) {
return (kind === 'notes' ? P.notes : P.markers) + videoId;
}
function read(kind, videoId) {
var raw;
try { raw = storage.getItem(keyOf(kind, videoId)); } catch (e) { return []; }
if (!raw) return [];
try {
var parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return kind === 'notes' ? parsed.map(Util.normalizeNote) : parsed.map(Util.normalizeMarker);
} catch (e2) {
return [];
}
}
function write(kind, videoId, list) {
var norm = (Array.isArray(list) ? list : []).map(kind === 'notes' ? Util.normalizeNote : Util.normalizeMarker);
var payload = JSON.stringify(Util.sortByTime(norm));
try {
storage.setItem(keyOf(kind, videoId), payload);
return { ok: true, count: norm.length };
} catch (e) {
var quota = e && (e.name === 'QuotaExceededError' ||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
e.code === 22 || e.code === 1014);
return {
ok: false,
quota: !!quota,
message: quota
? '浏览器本地存储已满,本次修改未保存。请删除部分旧笔记后重试,或用「导出」备份后清理。'
: ('保存失败:' + String((e && e.message) || e))
};
}
}
return {
keyOf: keyOf,
loadMarkers: function (videoId) { return read('markers', videoId); },
saveMarkers: function (videoId, list) { return write('markers', videoId, list); },
loadNotes: function (videoId) { return read('notes', videoId); },
saveNotes: function (videoId, list) { return write('notes', videoId, list); },
exportJSON: function (videoId, markers, notes) {
return JSON.stringify({
app: 'htknow-enhancer',
version: VERSION,
videoId: videoId,
exportedAt: new Date().toISOString(),
markers: Util.sortByTime(markers || []),
notes: Util.sortByTime(notes || [])
}, null, 2);
}
};
}
var API = {
version: VERSION,
CONFIG: CONFIG,
Util: Util,
createStore: createStore
};
try { if (typeof globalThis !== 'undefined') globalThis.__HTKNOW_ENH__ = API; } catch (e) { }
if (typeof document === 'undefined' || typeof window === 'undefined') return;
var doc = document;
function log() {
if (!CONFIG.debug) return;
var args = Array.prototype.slice.call(arguments);
args.unshift('[htknow-enh]');
console.log.apply(console, args);
}
var store = createStore(window.localStorage, CONFIG.storagePrefix);
var state = {
video: null,
videoId: '',
keyMode: '',
markers: [],
notes: [],
overlay: null,
strip: null,
markerNodes: {},
noteNodes: {},
panel: null,
overlayParent: null,
overlayMode: '',
parentOrigPosition: null,
overlayHome: null,
controlBarOffset: 0,
stripGeom: null,
lastGeomAt: 0,
geomTimer: 0,
geomRaf: 0,
settleT1: 0, settleT2: 0,
barRO: null,
barROEl: null,
panelHome: null,
fitSaved: null,
panelHome: null,
prefsCollapsedBeforeFs: null,
prefs: {},
bound: null,
keyMap: {},
urlSnapshot: '',
rectTimer: 0,
lastMouse: { x: -1, y: -1 },
toastTimer: 0,
fileInput: null
};
function loadPrefs() {
try {
var raw = window.localStorage.getItem(CONFIG.prefsKey);
var o = raw ? JSON.parse(raw) : null;
return (o && typeof o === 'object') ? o : {};
} catch (e) { return {}; }
}
function savePrefs(patch) {
state.prefs = Object.assign({}, state.prefs, patch);
try { window.localStorage.setItem(CONFIG.prefsKey, JSON.stringify(state.prefs)); } catch (e) { }
}
function el(tag, cls, html) {
var n = doc.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function on(target, type, fn, opts) {
if (!target) return;
target.addEventListener(type, fn, opts || false);
}
function debounce(fn, wait) {
var timer = 0;
return function () {
var args = arguments, self = this;
clearTimeout(timer);
timer = setTimeout(function () { fn.apply(self, args); }, wait);
};
}
function toast(msg, kind) {
var box = doc.getElementById('htknow-enh-toast');
if (!box) {
box = el('div', 'htknow-enh-toast');
box.id = 'htknow-enh-toast';
(doc.body || doc.documentElement).appendChild(box);
}
box.textContent = String(msg);
box.className = 'htknow-enh-toast is-show' + (kind === 'error' ? ' is-error' : '');
clearTimeout(state.toastTimer);
state.toastTimer = setTimeout(function () {
box.className = 'htknow-enh-toast' + (kind === 'error' ? ' is-error' : '');
}, kind === 'error' ? 4200 : 1800);
}
function isElementVisible(v) {
if (!v || !v.getBoundingClientRect) return false;
var r = v.getBoundingClientRect();
if (r.width < 80 || r.height < 45) return false;
var cs = window.getComputedStyle(v);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
if (Number(cs.opacity) === 0) return false;
return true;
}
function collectVideosIn(root, out, depth) {
out = out || [];
depth = depth || 0;
if (!root || depth > 6 || !root.querySelectorAll) return out;
try {
var direct = root.querySelectorAll('video');
for (var i = 0; i < direct.length; i++) out.push(direct[i]);
} catch (e) { }
if (!out.length) {
try {
var all = root.querySelectorAll('*');
for (var j = 0; j < all.length; j++) {
if (all[j].shadowRoot) collectVideosIn(all[j].shadowRoot, out, depth + 1);
}
} catch (e2) { }
}
return out;
}
function pickVideo() {
var vids = collectVideosIn(doc, [], 0);
if (!vids.length) return null;
var best = null, bestScore = -Infinity;
for (var i = 0; i < vids.length; i++) {
var v = vids[i];
var score = 0;
if (isElementVisible(v)) score += 1000;
var dur = isFinite(v.duration) ? v.duration : 0;
var cur = isFinite(v.currentTime) ? v.currentTime : 0;
score += Math.min(dur, 100000) * 0.01;
score += Math.min(cur, 100000) * 0.02;
if (v.paused === false && !v.ended) score += 500;
if (score > bestScore) { bestScore = score; best = v; }
}
return best;
}
function computeVideoId() {
var r = Util.videoKeyFrom(window.location, state.video);
state.keyMode = r.mode;
return r.key;
}
function teardownOverlay() {
if (state.rectTimer) { clearInterval(state.rectTimer); state.rectTimer = 0; }
restoreOverlayHome();
if (state.overlay && state.overlay.parentNode) state.overlay.parentNode.removeChild(state.overlay);
if (state.overlayParent && state.parentOrigPosition != null) {
try { state.overlayParent.style.position = state.parentOrigPosition; } catch (e) { }
}
state.overlay = null;
state.strip = null;
state.markerNodes = {};
state.noteNodes = {};
state.overlayParent = null;
state.parentOrigPosition = null;
state.overlayMode = '';
state.overlayHome = null;
state.stripGeom = null;
state.lastGeomAt = 0;
if (state.barRO) { try { state.barRO.disconnect(); } catch (e) { } }
state.barRO = null;
state.barROEl = null;
if (state.geomRaf) {
try { (window.cancelAnimationFrame || clearTimeout)(state.geomRaf); } catch (e) { }
state.geomRaf = 0;
}
if (state.settleT1) { clearTimeout(state.settleT1); state.settleT1 = 0; }
if (state.settleT2) { clearTimeout(state.settleT2); state.settleT2 = 0; }
}
function restoreOverlayHome() {
var home = state.overlayHome;
if (!home) return;
state.overlayHome = null;
if (!state.overlay) return;
try {
if (home.parent && doc.contains(home.parent)) {
home.parent.insertBefore(state.overlay, home.next && doc.contains(home.next) ? home.next : null);
} else if (state.video && state.video.parentElement) {
state.video.parentElement.appendChild(state.overlay);
}
} catch (e) { }
}
function findControlBar(scope, video) {
if (!scope || !scope.getBoundingClientRect) return null;
var sr = scope.getBoundingClientRect();
if (sr.width <= 0 || sr.height <= 0) return null;
var kids = scope.children;
for (var i = 0; i < kids.length; i++) {
var k = kids[i];
if (k === video || (k.contains && k.contains(video))) continue;
var r = k.getBoundingClientRect();
if (r.height < 8 || r.height > sr.height * 0.4) continue;
if (r.width < sr.width * 0.5) continue;
if (Math.abs(r.bottom - sr.bottom) > 8) continue;
var cs = window.getComputedStyle(k);
if (cs.position === 'static') continue;
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
return k;
}
return null;
}
function detectControlBarOffset() {
var v = state.video;
if (!v) return 0;
var node = v.parentElement;
for (var depth = 0; depth < 4 && node && node !== doc.body && node !== doc.documentElement; depth++) {
var bar = findControlBar(node, v);
if (bar) return Math.round(bar.getBoundingClientRect().height);
node = node.parentElement;
}
return 0;
}
function findPlayerContainer(video) {
if (!video || !video.parentElement) return null;
var vr = video.getBoundingClientRect();
var node = video.parentElement;
var fallback = null;
for (var i = 0; i < 5 && node && node !== doc.body && node !== doc.documentElement; i++) {
var nr = node.getBoundingClientRect();
if (nr.width <= 0 || nr.height <= 0) break;
if (findControlBar(node, video)) return node;
if (!fallback && nr.width <= vr.width * 1.8 + 60 && nr.height <= vr.height * 1.8 + 60) fallback = node;
node = node.parentElement;
}
return fallback || video.parentElement;
}
function videoBox(video, scope) {
var vr = video.getBoundingClientRect();
var box = { left: vr.left, top: vr.top, right: vr.right, bottom: vr.bottom, width: vr.width, height: vr.height };
if (!(box.height > 0) && scope && scope.getBoundingClientRect) {
var sr = scope.getBoundingClientRect();
if (sr.height > 0) { box.top = sr.top; box.bottom = sr.bottom; box.height = sr.height; }
}
if (!(box.height > 0) && box.width > 0) box.height = box.width * 0.75;
return box;
}
function findProgressBar(video, scope, box) {
if (!video || !scope || !scope.querySelectorAll) return null;
box = box || videoBox(video, scope);
var vw = box.width, vh = box.height;
if (!(vw > 0) || !(vh > 0)) return null;
var sels = ['.vjs-progress-holder', '.vjs-slider-bar', '.vjs-progress-control',
'[class*="progress"]', '[class*="slider"]', '[class*="track"]', '[class*="timeline"]'];
for (var s = 0; s < sels.length; s++) {
var list;
try { list = scope.querySelectorAll(sels[s]); } catch (e) { continue; }
var best = null, bestArea = Infinity;
for (var i = 0; i < list.length; i++) {
var node = list[i];
if (node.id === 'htknow-enh-strip' || node.id === 'htknow-enh-overlay') continue;
if (typeof node.className === 'string' && node.className.indexOf('htknow-enh') >= 0) continue;
var r = node.getBoundingClientRect();
if (!(r.width >= vw * 0.25)) continue;
if (!(r.height >= 1) || r.height > vh * 0.2) continue;
var area = r.width * r.height;
if (area < bestArea) { bestArea = area; best = node; }
}
if (best) return best;
}
return null;
}
function findProgressBarAnywhere(video) {
var box = videoBox(video, state.overlayParent);
var bar = findProgressBar(video, doc, box);
if (!bar) return null;
var r = bar.getBoundingClientRect();
var cy = r.top + r.height / 2;
if (cy < box.top || cy > box.bottom) return null;
if (r.left < box.left - 8 || r.right > box.right + 8) return null;
return bar;
}
function overlayRefRect() {
var cands = [state.overlay, state.overlayParent];
for (var i = 0; i < cands.length; i++) {
var n = cands[i];
if (!n || !n.getBoundingClientRect) continue;
var r = n.getBoundingClientRect();
if (r.width > 0 && r.height > 0) return r;
}
return null;
}
function measureStripGeometry() {
var video = state.video;
if (!video) return null;
var host = state.overlayParent;
var bar = host ? findProgressBar(video, host) : null;
if (!bar) bar = findProgressBarAnywhere(video);
if (!bar) return null;
var r = bar.getBoundingClientRect();
if (r.width < 10 || r.height < 1) return null;
var ref = overlayRefRect();
if (!ref) return null;
return {
left: r.left - ref.left,
width: r.width,
bottom: ref.bottom - r.bottom,
height: r.height,
refW: ref.width,
refH: ref.height,
el: bar,
rawLeft: r.left,
rawWidth: r.width,
rawBottom: r.bottom
};
}
function observeBar(bar) {
if (!bar || typeof ResizeObserver === 'undefined') return;
if (state.barRO && state.barROEl === bar) return;
if (state.barRO) { try { state.barRO.disconnect(); } catch (e) { } }
state.barRO = null;
state.barROEl = bar;
try {
state.barRO = new ResizeObserver(function () { scheduleGeomApply(); });
state.barRO.observe(bar);
if (bar.parentElement) state.barRO.observe(bar.parentElement);
} catch (e) { state.barRO = null; state.barROEl = null; }
}
function scheduleGeomApply() {
if (state.geomRaf) return;
var raf = window.requestAnimationFrame || function (fn) { return setTimeout(fn, 16); };
state.geomRaf = raf(function () {
state.geomRaf = 0;
applyStripGeometry();
});
}
function geometryHeartbeat() {
/* 先同步可见性:未开播时量不到轨道(stripGeom 为 null),不能因为下面的 return 就不判可见性 */
syncStripVisibility();
var g = state.stripGeom;
if (!state.strip || !g || !g.el || !doc.contains(g.el)) return;
var r = g.el.getBoundingClientRect();
if (r.width < 10 || r.height < 1) return;
if (Math.abs(r.left - g.rawLeft) > 0.5 || Math.abs(r.width - g.rawWidth) > 0.5 ||
Math.abs(r.bottom - g.rawBottom) > 0.5) {
applyStripGeometry();
return;
}
if (!state.prefs.panel && state.panel && state.video) {
var vr = state.video.getBoundingClientRect();
var sc = window.scrollY || 0;
var home = state.panelHome;
if (!home) {
state.panelHome = { left: vr.left, top: vr.top, scroll: sc };
} else if (Math.abs(sc - home.scroll) < 2 &&
(Math.abs(vr.left - home.left) > 12 || Math.abs(vr.top - home.top) > 12)) {
state.panelHome = { left: vr.left, top: vr.top, scroll: sc };
autoPlacePanel();
}
}
}
function settleGeometry() {
if (state.settleT1) clearTimeout(state.settleT1);
if (state.settleT2) clearTimeout(state.settleT2);
state.settleT1 = setTimeout(function () { applyStripGeometry(); }, 220);
state.settleT2 = setTimeout(function () { applyStripGeometry(); }, 620);
syncStripVisibility();
}
function scaledStripGeom(g, refW) {
if (!g || !(g.refW > 0) || !(refW > 0) || Math.abs(refW - g.refW) < 1) return g;
return {
left: g.left,
width: Math.max(10, g.width + (refW - g.refW)),
bottom: g.bottom,
height: g.height, refW: refW, refH: g.refH, el: g.el
};
}
function applyStripGeometry() {
if (!state.strip) return;
if (state.stripGeom && state.stripGeom.el && !doc.contains(state.stripGeom.el)) state.stripGeom = null;
var ref = overlayRefRect();
var fresh = measureStripGeometry();
if (fresh) { state.stripGeom = fresh; observeBar(fresh.el); }
var g = fresh || scaledStripGeom(state.stripGeom, ref ? ref.width : 0);
if (g) {
var stripH = CONFIG.stripHeight;
var bottom = Math.max(0, g.bottom + g.height / 2 - stripH / 2);
state.strip.style.setProperty('left', g.left + 'px', 'important');
state.strip.style.setProperty('width', g.width + 'px', 'important');
state.strip.style.setProperty('right', 'auto', 'important');
state.strip.style.setProperty('bottom', bottom + 'px', 'important');
syncStripVisibility();
return bottom;
}
state.controlBarOffset = CONFIG.autoAvoidControlBar ? detectControlBarOffset() : 0;
var off = Math.max(CONFIG.stripOffsetBottom, state.controlBarOffset);
state.strip.style.setProperty('left', '0px', 'important');
state.strip.style.setProperty('right', '0px', 'important');
state.strip.style.removeProperty('width');
state.strip.style.setProperty('bottom', off + 'px', 'important');
syncStripVisibility();
return off;
}
function refreshStripGeometry() {
if (!state.strip) return;
var now = Date.now();
if (now - state.lastGeomAt < 500) return;
state.lastGeomAt = now;
applyStripGeometry();
}
function attachOverlay() {
var video = state.video;
if (!video) return;
var parent = video.parentElement;
var overlay = el('div');
overlay.id = 'htknow-enh-overlay';
var strip = el('div', 'htknow-enh-strip');
strip.id = 'htknow-enh-strip';
strip.style.height = CONFIG.stripHeight + 'px';
overlay.appendChild(strip);
if (parent && parent !== doc.body && parent !== doc.documentElement) {
state.overlayMode = 'absolute';
try {
var cs = window.getComputedStyle(parent);
if (cs.position === 'static') {
state.parentOrigPosition = parent.style.position || '';
parent.style.position = 'relative';
}
} catch (e) { }
state.overlayParent = parent;
parent.appendChild(overlay);
} else {
state.overlayMode = 'fixed';
overlay.classList.add('htknow-enh-overlay-fix');
(doc.body || doc.documentElement).appendChild(overlay);
syncOverlayRect();
state.rectTimer = setInterval(syncOverlayRect, 500);
}
state.overlay = overlay;
state.strip = strip;
applyStripGeometry();
renderStrip();
}
function syncOverlayRect() {
if (state.overlayMode !== 'fixed' || !state.overlay || !state.video) return;
var r = state.video.getBoundingClientRect();
var s = state.overlay.style;
s.left = r.left + 'px';
s.top = r.top + 'px';
s.width = r.width + 'px';
s.height = r.height + 'px';
}
function renderStrip() {
var strip = state.strip;
if (!strip) return;
strip.innerHTML = '';
state.markerNodes = {};
state.noteNodes = {};
var dur = state.video && isFinite(state.video.duration) ? state.video.duration : 0;
state.markers.forEach(function (m) {
var pct = dur > 0 ? Util.pctOf(m.t, dur) : 0;
var node = el('div', 'htknow-enh-marker');
node.style.left = pct + '%';
node.setAttribute('data-id', m.id);
node.title = Util.fmtTime(m.t) + (m.label ? ' · ' + m.label : '') + '\n单击跳转 · 右键删除';
node.innerHTML = '' + CONFIG.markerSymbol + ' ';
on(node, 'click', function (e) {
e.preventDefault(); e.stopPropagation();
jumpTo(m.t);
});
on(node, 'contextmenu', function (e) {
e.preventDefault(); e.stopPropagation();
deleteMarker(m.id);
});
strip.appendChild(node);
state.markerNodes[m.id] = node;
});
/* 收藏在进度条上是五角星(用户要求:收藏=★、标记=?) */
state.notes.forEach(function (n) {
var pct = dur > 0 ? Util.pctOf(n.t, dur) : 0;
var node = el('div', 'htknow-enh-marker is-note');
node.style.left = pct + '%';
node.setAttribute('data-id', n.id);
node.title = Util.fmtTime(n.t) + (n.text ? ' · ' + n.text : '') + '\n单击跳转 · 右键删除';
node.innerHTML = '' + CONFIG.noteSymbol + ' ';
on(node, 'click', function (e) {
e.preventDefault(); e.stopPropagation();
jumpTo(n.t);
});
on(node, 'contextmenu', function (e) {
e.preventDefault(); e.stopPropagation();
deleteNote(n.id);
});
strip.appendChild(node);
state.noteNodes[n.id] = node;
});
syncStripVisibility();
highlightActiveMarker();
}
/** 站点控制条元素:优先 Video.js 标准类名(隐藏时也找得到 —— 几何探测在 display:none 时必然落空),
再退到已量到的轨道元素。 */
function controlBarEl() {
var v = state.video;
var node = v && v.parentElement;
for (var i = 0; i < 4 && node && node !== doc.body && node !== doc.documentElement; i++) {
var bar = node.querySelector ? node.querySelector('.vjs-control-bar') : null;
if (bar) return bar;
node = node.parentElement;
}
return (state.stripGeom && state.stripGeom.el) || null;
}
/** 元素「实际」是否可见:祖先的 display/visibility/opacity 都会影响它。
opacity 不被子元素继承 —— getComputedStyle(子).opacity 永远是 1,
所以站点给控制条写 opacity:0 时,必须自己沿祖先链把 opacity 乘起来才看得出隐藏。 */
function effectiveVisible(el) {
var node = el, op = 1;
for (var i = 0; i < 12 && node && node.nodeType === 1; i++) {
var cs = window.getComputedStyle(node);
if (cs.display === 'none') return false;
if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false;
var o = parseFloat(cs.opacity);
if (isFinite(o)) op *= o;
if (op <= 0.05) return false;
if (node === doc.body || node === doc.documentElement) break;
node = node.parentElement;
}
return true;
}
/** 控制条(进度条)真的显示时才让标记露出来 —— 站点用 display:none / visibility:hidden / opacity:0 藏它 */
function syncStripVisibility() {
if (!state.strip) return;
var el = controlBarEl();
var show = true;
if (el && doc.contains(el)) {
var r = el.getBoundingClientRect();
show = r.width > 0.5 && r.height > 0.5 && effectiveVisible(el);
}
state.strip.classList.toggle('is-bar-hidden', !show);
}
function highlightActiveMarker() {
var v = state.video;
if (!v) return;
var t = v.currentTime || 0;
var activeId = null;
for (var i = 0; i < state.markers.length; i++) {
if (state.markers[i].t <= t + 0.25) activeId = state.markers[i].id;
else break;
}
for (var id in state.markerNodes) {
if (!Object.prototype.hasOwnProperty.call(state.markerNodes, id)) continue;
var node = state.markerNodes[id];
if (id === activeId) node.classList.add('is-active');
else node.classList.remove('is-active');
}
}
function persist(kind) {
var r = kind === 'notes'
? store.saveNotes(state.videoId, state.notes)
: store.saveMarkers(state.videoId, state.markers);
if (!r.ok) toast(r.message, 'error');
return r.ok;
}
function jumpTo(t) {
var v = state.video;
if (!v) return;
try {
v.currentTime = Util.clamp(t, 0, isFinite(v.duration) && v.duration > 0 ? v.duration : t);
if (CONFIG.jumpAutoplay && v.paused) {
var p = v.play();
if (p && p.catch) p.catch(function () { });
}
renderClock();
highlightActiveMarker();
} catch (e) {
toast('跳转失败:' + String(e && e.message || e), 'error');
}
}
function addMarkerAt(t, label) {
var item = Util.normalizeMarker({ id: Util.createId(), t: t, label: label || '' });
state.markers = Util.addItem(state.markers, item);
if (persist('markers')) {
renderStrip();
renderLists();
return item;
}
return null;
}
function deleteMarker(id) {
var m = Util.findItem(state.markers, id);
if (!m) return;
if (!window.confirm('删除标记 ' + Util.fmtTime(m.t) + (m.label ? '(' + m.label + ')' : '') + ' ?')) return;
state.markers = Util.removeItem(state.markers, id);
persist('markers');
renderStrip();
renderLists();
}
function addNoteAt(t, text) {
var item = Util.normalizeNote({ id: Util.createId(), t: t, text: text || '' });
state.notes = Util.addItem(state.notes, item);
if (persist('notes')) {
renderLists();
return item;
}
return null;
}
function deleteNote(id) {
var n = Util.findItem(state.notes, id);
if (!n) return;
if (!window.confirm('删除收藏 ' + Util.fmtTime(n.t) + ' ?笔记内容也会一并删除。')) return;
state.notes = Util.removeItem(state.notes, id);
persist('notes');
renderLists();
}
var PANEL_HTML = [
'
',
' ▶ ',
' 视频助手 ',
' --:-- / --:-- ',
' ',
' – ',
'
',
'',
' + 标记 ',
' ★ 收藏 ',
' 导出 ',
' 导入 ',
'
',
'',
' 倍速 ',
' ',
' ',
'
',
'',
' 标记 0 ',
' 收藏笔记 0 ',
'
',
'',
'',
' '
].join('\n');
function buildPanel() {
if (state.panel && doc.contains(state.panel)) return state.panel;
var p = el('div');
p.id = 'htknow-enh-panel';
p.innerHTML = PANEL_HTML;
(doc.body || doc.documentElement).appendChild(p);
var sel = p.querySelector('#htknow-enh-speed');
CONFIG.speedPresets.forEach(function (s) {
var o = doc.createElement('option');
o.value = String(s);
o.textContent = s + 'x';
sel.appendChild(o);
});
on(sel, 'change', function () { setSpeed(parseFloat(sel.value), true); });
on(p, 'click', onPanelClick);
on(p, 'change', onPanelChange);
on(p, 'keydown', function (e) {
if (e.code === 'Escape' && Util.isTypingTarget(e.target)) e.target.blur();
});
var head = p.querySelector('#htknow-enh-drag');
on(head, 'mousedown', onDragStart);
state.fileInput = p.querySelector('#htknow-enh-file');
on(state.fileInput, 'change', onImportFile);
state.panel = p;
applyPanelPos();
setCollapsed(state.prefs.collapsed === true);
return p;
}
function onPanelClick(e) {
var btn = e.target && e.target.closest ? e.target.closest('[data-act]') : null;
if (!btn) return;
var act = btn.getAttribute('data-act');
var id = btn.getAttribute('data-id');
var v = state.video;
if (act === 'collapse') { setCollapsed(!isCollapsed()); return; }
if (act === 'tab') { switchTab(btn.getAttribute('data-tab')); return; }
if (act === 'jump') {
var item = Util.findItem(state.markers, id) || Util.findItem(state.notes, id);
if (item) jumpTo(item.t);
return;
}
if (act === 'add-marker') {
if (!v) { toast('还没有找到视频', 'error'); return; }
var m = addMarkerAt(v.currentTime, '');
if (m) { toast('已添加标记 ' + Util.fmtTime(v.currentTime) + (maybeFocus('markers', m.id) ? ' · 可直接输入,Esc 返回快捷键' : '')); }
return;
}
if (act === 'add-note') {
if (!v) { toast('还没有找到视频', 'error'); return; }
var n = addNoteAt(v.currentTime, '');
if (n) { switchTab('notes'); toast('已收藏 ' + Util.fmtTime(v.currentTime) + (maybeFocus('notes', n.id) ? ' · 可直接输入,Esc 返回快捷键' : '')); }
return;
}
if (act === 'del-marker') { deleteMarker(id); return; }
if (act === 'del-note') { deleteNote(id); return; }
if (act === 'export') { exportData(); return; }
if (act === 'import') { if (state.fileInput) state.fileInput.click(); return; }
}
function onPanelChange(e) {
var t = e.target;
if (!t || !t.getAttribute) return;
var act = t.getAttribute('data-act');
var id = t.getAttribute('data-id');
if (act === 'label') {
state.markers = Util.updateItem(state.markers, id, { label: String(t.value || '') });
persist('markers');
renderStrip();
renderCounts();
} else if (act === 'note-text') {
state.notes = Util.updateItem(state.notes, id, { text: String(t.value || ''), updatedAt: Date.now() });
persist('notes');
renderCounts();
}
}
function focusItem(kind, id) {
var pane = doc.getElementById(kind === 'notes' ? 'htknow-enh-pane-notes' : 'htknow-enh-pane-markers');
if (!pane) return;
var node = pane.querySelector('[data-id="' + id + '"]');
if (!node) return;
var field = node.querySelector('input, textarea');
if (!field) return;
requestAnimationFrame(function () {
try { field.focus(); field.select && field.select(); } catch (e) { }
});
}
function maybeFocus(kind, id) {
var allow = kind === 'notes' ? CONFIG.autoFocusNewNote : CONFIG.autoFocusNewMarker;
if (!allow) return false;
focusItem(kind, id);
return true;
}
function switchTab(tab) {
if (!state.panel) return;
state.panel.querySelectorAll('.htknow-enh-tab').forEach(function (b) {
b.classList.toggle('is-active', b.getAttribute('data-tab') === tab);
});
state.panel.querySelector('#htknow-enh-pane-markers').classList.toggle('is-active', tab === 'markers');
state.panel.querySelector('#htknow-enh-pane-notes').classList.toggle('is-active', tab === 'notes');
savePrefs({ tab: tab });
}
function renderLists() {
renderMarkers();
renderNotes();
renderCounts();
}
function renderCounts() {
if (!state.panel) return;
var a = state.panel.querySelector('#htknow-enh-cnt-markers');
var b = state.panel.querySelector('#htknow-enh-cnt-notes');
if (a) a.textContent = String(state.markers.length);
if (b) b.textContent = String(state.notes.length);
}
function renderMarkers() {
if (!state.panel) return;
var list = state.panel.querySelector('#htknow-enh-list-markers');
if (!list) return;
if (!state.markers.length) {
list.innerHTML = '还没有标记点。 播放到关键位置,按 M 或点上方「+ 标记」。
';
return;
}
list.innerHTML = state.markers.map(function (m) {
return '' +
'' + Util.fmtTime(m.t) + ' ' +
' ' +
'× ' +
'
';
}).join('');
}
function renderNotes() {
if (!state.panel) return;
var list = state.panel.querySelector('#htknow-enh-list-notes');
if (!list) return;
if (!state.notes.length) {
list.innerHTML = '还没有收藏。 播放到重点,按 B 或点上方「★ 收藏」,然后写笔记。
';
return;
}
list.innerHTML = state.notes.map(function (n) {
return '' +
'
' +
'' + CONFIG.noteSymbol + ' ' +
'' + Util.fmtTime(n.t) + ' ' +
' ' +
'× ' +
'
' +
'
' +
'
';
}).join('');
}
function renderClock() {
if (!state.panel) return;
var clock = state.panel.querySelector('#htknow-enh-clock');
if (!clock) return;
var v = state.video;
if (!v) { clock.textContent = '--:-- / --:--'; return; }
var d = isFinite(v.duration) && v.duration > 0 ? Util.fmtTime(v.duration) : '--:--';
clock.textContent = Util.fmtTime(v.currentTime || 0) + ' / ' + d;
}
function renderVideoId() {
if (!state.panel) return;
var n = state.panel.querySelector('#htknow-enh-vid');
if (!n) return;
n.textContent = state.videoId ? (state.keyMode === 'params' ? 'ID ' : '路径 ') + state.videoId.slice(0, 22) : '';
n.title = '存储键:' + store.keyOf('markers', state.videoId) + '\n' + store.keyOf('notes', state.videoId);
}
function syncSpeedSelect() {
if (!state.panel) return;
var sel = state.panel.querySelector('#htknow-enh-speed');
if (!sel || !state.video) return;
var rate = state.video.playbackRate || 1;
sel.value = String(rate);
if (sel.selectedIndex < 0) {
var o = doc.createElement('option');
o.value = String(rate);
o.textContent = rate + 'x';
sel.appendChild(o);
sel.value = String(rate);
}
}
function isCollapsed() {
return !!(state.panel && state.panel.classList.contains('is-collapsed'));
}
function setCollapsed(v) {
if (!state.panel) return;
state.panel.classList.toggle('is-collapsed', !!v);
savePrefs({ collapsed: !!v });
}
function applyPanelPos() {
if (!state.panel) return;
var saved = state.prefs.panel;
if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') {
setPanelPos(saved.x, saved.y);
} else {
autoPlacePanel();
}
}
function setPanelPos(x, y) {
if (!state.panel) return;
var w = state.panel.offsetWidth || 320;
var h = state.panel.offsetHeight || 200;
var nx = Util.clamp(x, 8, Math.max(8, window.innerWidth - w - 8));
var ny = Util.clamp(y, 8, Math.max(8, window.innerHeight - h - 8));
state.panel.style.left = nx + 'px';
state.panel.style.top = ny + 'px';
}
function autoPlacePanel() {
var v = state.video;
if (!v || !state.panel) return;
var r = v.getBoundingClientRect();
var w = state.panel.offsetWidth || 320;
var h = state.panel.offsetHeight || 200;
var x, y;
if (r.width > 320 && r.right + w + 16 <= window.innerWidth) {
x = r.right + 12;
y = r.top;
} else if (r.bottom + Math.min(h, 260) + 12 <= window.innerHeight) {
x = Math.max(12, r.left);
y = r.bottom + 12;
} else {
x = window.innerWidth - w - 16;
y = 76;
}
var maxX = Math.max(8, window.innerWidth - w - 8);
if (x > maxX) {
x = maxX;
var ph = Math.min(h, 400);
var hit = x < r.right + 8 && x + w > r.left - 8 && y < r.bottom + 8 && y + ph > r.top - 8;
if (hit && r.bottom + ph + 12 <= window.innerHeight) {
x = Math.max(12, r.left);
y = r.bottom + 12;
}
}
setPanelPos(x, y);
}
function onDragStart(e) {
if (e.button !== 0) return;
var t = e.target;
if (t && t.closest && t.closest('button, select, input, textarea')) return;
e.preventDefault();
var p = state.panel;
var rect = p.getBoundingClientRect();
var offX = e.clientX - rect.left;
var offY = e.clientY - rect.top;
function move(ev) {
setPanelPos(ev.clientX - offX, ev.clientY - offY);
}
function up() {
doc.removeEventListener('mousemove', move, true);
doc.removeEventListener('mouseup', up, true);
var r2 = p.getBoundingClientRect();
savePrefs({ panel: { x: Math.round(r2.left), y: Math.round(r2.top) } });
}
doc.addEventListener('mousemove', move, true);
doc.addEventListener('mouseup', up, true);
}
function toggleFullscreen(video) {
var fsEl = doc.fullscreenElement || doc.webkitFullscreenElement || doc.msFullscreenElement;
if (fsEl) {
var exit = doc.exitFullscreen || doc.webkitExitFullscreen || doc.msExitFullscreen;
if (exit) { try { var r = exit.call(doc); if (r && r.catch) r.catch(function () {}); } catch (e) { } }
return;
}
var target = findPlayerContainer(video) || video;
var req = target.requestFullscreen || target.webkitRequestFullscreen || target.msRequestFullscreen ||
video.requestFullscreen || video.webkitRequestFullscreen;
if (!req) { toast('当前浏览器不支持全屏 API', 'error'); return; }
try {
var p = req.call(target);
if (p && p.catch) p.catch(function () { toast('全屏被拒绝(可能不是用户手势触发)', 'error'); });
} catch (e) {
toast('全屏失败:' + String(e && e.message || e), 'error');
}
}
function stretchVideoToFill(fsEl, video) {
if (!CONFIG.stretchVideoInFullscreen || !fsEl || !video || fsEl === video) return;
var chain = [];
var node = video;
while (node && node !== fsEl && node !== doc.body && node !== doc.documentElement) {
chain.push(node);
node = node.parentElement;
}
if (node !== fsEl) return;
state.fitSaved = [];
chain.forEach(function (elm, i) {
state.fitSaved.push({ el: elm, styleAttr: elm.getAttribute('style') });
var s = elm.style;
s.setProperty('width', '100%', 'important');
s.setProperty('height', '100%', 'important');
s.setProperty('max-width', 'none', 'important');
s.setProperty('max-height', 'none', 'important');
if (elm === video) {
s.setProperty('object-fit', CONFIG.fullscreenObjectFit, 'important');
s.setProperty('display', 'block', 'important');
} else {
s.setProperty('margin', '0', 'important');
}
});
}
function unstretchVideo() {
if (!state.fitSaved) return;
state.fitSaved.forEach(function (rec) {
try {
if (rec.styleAttr === null) rec.el.removeAttribute('style');
else rec.el.setAttribute('style', rec.styleAttr);
} catch (e) { }
});
state.fitSaved = null;
}
function reparentOverlayToFullscreen(fsEl) {
var ov = state.overlay;
if (!ov || !fsEl || ov.parentNode === fsEl) return;
if (!state.overlayHome) {
state.overlayHome = { parent: ov.parentNode, next: ov.nextSibling };
}
try {
fsEl.appendChild(ov);
var s = ov.style;
s.position = 'absolute';
s.left = '0'; s.top = '0'; s.right = '0'; s.bottom = '0';
s.width = 'auto'; s.height = 'auto';
state.overlayMode = 'absolute';
if (state.rectTimer) { clearInterval(state.rectTimer); state.rectTimer = 0; }
} catch (e) { }
}
function onFullscreenChange() {
var fsEl = doc.fullscreenElement || doc.webkitFullscreenElement || doc.msFullscreenElement;
var panel = state.panel;
if (fsEl) {
if (state.video) {
stretchVideoToFill(fsEl, state.video);
reparentOverlayToFullscreen(fsEl);
applyStripGeometry();
setTimeout(function () { if (state.strip) applyStripGeometry(); }, 120);
}
if (panel) {
if (!state.panelHome) state.panelHome = { parent: panel.parentNode, next: panel.nextSibling };
try { fsEl.appendChild(panel); } catch (e) { }
if (state.prefsCollapsedBeforeFs === null) state.prefsCollapsedBeforeFs = isCollapsed();
if (!isCollapsed()) {
panel.classList.add('is-collapsed');
}
setPanelPos(window.innerWidth - 340, 16);
}
} else {
unstretchVideo();
restoreOverlayHome();
if (state.prefsCollapsedBeforeFs !== null) {
var wasCollapsed = state.prefsCollapsedBeforeFs;
state.prefsCollapsedBeforeFs = null;
if (panel) panel.classList.toggle('is-collapsed', wasCollapsed);
}
if (panel && state.panelHome) {
var home = state.panelHome;
state.panelHome = null;
try {
if (home.parent && doc.contains(home.parent)) {
home.parent.insertBefore(panel, home.next && doc.contains(home.next) ? home.next : null);
} else {
(doc.body || doc.documentElement).appendChild(panel);
}
} catch (e2) { }
applyPanelPos();
}
if (state.strip) applyStripGeometry();
}
setTimeout(syncOverlayRect, 60);
}
function setSpeed(rate, persistIt) {
var v = state.video;
if (!v) return;
var r = Util.clamp(rate, 0.25, 4);
try { v.playbackRate = r; } catch (e) { }
syncSpeedSelect();
if (persistIt && CONFIG.rememberSpeed) savePrefs({ speed: r });
toast('倍速 ' + r + 'x');
}
function stepSpeed(dir) {
var v = state.video;
if (!v) return;
var presets = CONFIG.speedPresets;
var cur = v.playbackRate || 1;
var idx = 0, bestDiff = Infinity;
for (var i = 0; i < presets.length; i++) {
var d = Math.abs(presets[i] - cur);
if (d < bestDiff) { bestDiff = d; idx = i; }
}
var next = Util.clamp(idx + dir, 0, presets.length - 1);
setSpeed(presets[next], true);
}
function setVolume(vol) {
var v = state.video;
if (!v) return;
var nv = Util.clamp(vol, 0, 1);
v.volume = nv;
v.muted = nv === 0;
toast('音量 ' + Math.round(nv * 100) + '%');
}
function exportData() {
try {
var text = store.exportJSON(state.videoId, state.markers, state.notes);
var blob = new Blob([text], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = doc.createElement('a');
a.href = url;
a.download = 'htknow-' + String(state.videoId).replace(/[^A-Za-z0-9_-]/g, '') + '-' +
new Date().toISOString().slice(0, 10) + '.json';
(doc.body || doc.documentElement).appendChild(a);
a.click();
setTimeout(function () {
URL.revokeObjectURL(url);
if (a.parentNode) a.parentNode.removeChild(a);
}, 1000);
toast('已导出 ' + state.markers.length + ' 个标记 / ' + state.notes.length + ' 条笔记');
} catch (e) {
toast('导出失败:' + String(e && e.message || e), 'error');
}
}
function onImportFile(e) {
var f = e.target && e.target.files && e.target.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function () {
try {
var data = Util.parseImport(String(reader.result || ''));
var inMarkers = data.markers.map(Util.normalizeMarker);
var inNotes = data.notes.map(Util.normalizeNote);
var fm = Util.fillEmptyText(state.markers, inMarkers, 'label');
var fn = Util.fillEmptyText(state.notes, inNotes, 'text');
var rm = Util.mergeByKey(fm.list, inMarkers,
function (m) { return Math.round(m.t * 10) + '|' + m.label; });
var rn = Util.mergeByKey(fn.list, inNotes,
function (n) { return Math.round(n.t * 10) + '|' + n.text; });
state.markers = Util.sortByTime(rm.list);
state.notes = Util.sortByTime(rn.list);
persist('markers');
persist('notes');
renderStrip();
renderLists();
var extra = (fm.filled || fn.filled) ? ',补全 ' + (fm.filled + fn.filled) + ' 条' : '';
toast('导入完成:新增 ' + rm.added + ' 个标记 / ' + rn.added + ' 条笔记' + extra);
} catch (err) {
toast('导入失败:' + String(err && err.message || err), 'error');
} finally {
if (state.fileInput) state.fileInput.value = '';
}
};
reader.onerror = function () { toast('读取文件失败', 'error'); };
reader.readAsText(f, 'utf-8');
}
function buildKeyMap() {
var map = {};
for (var action in CONFIG.keys) {
if (!Object.prototype.hasOwnProperty.call(CONFIG.keys, action)) continue;
var codes = CONFIG.keys[action];
for (var i = 0; i < codes.length; i++) map[codes[i]] = action;
}
return map;
}
function isMediaEngaged() {
var v = state.video;
if (!v) return false;
if (v.paused === false && !v.ended) return true;
var m = state.lastMouse;
if (m.x < 0) return false;
var r = v.getBoundingClientRect();
return m.x >= r.left && m.x <= r.right && m.y >= r.top && m.y <= r.bottom;
}
function onKeyDown(e) {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.repeat && e.code !== 'ArrowLeft' && e.code !== 'ArrowRight' &&
e.code !== 'ArrowUp' && e.code !== 'ArrowDown') return;
if (Util.isTypingTarget(e.target) || Util.isTypingTarget(doc.activeElement)) return;
var action = state.keyMap[e.code];
if (!action) return;
var v = state.video;
if (!v) return;
if (GUARDED_ACTIONS[action] && !isMediaEngaged()) return;
var handled = true;
switch (action) {
case 'playPause':
if (v.paused || v.ended) {
var p = v.play();
if (p && p.catch) p.catch(function () { });
} else {
v.pause();
}
break;
case 'fullscreen':
toggleFullscreen(v);
break;
case 'addMarker': {
var m = addMarkerAt(v.currentTime, '');
if (m) { toast('已添加标记 ' + Util.fmtTime(v.currentTime) + (maybeFocus('markers', m.id) ? ' · 可直接输入,Esc 返回快捷键' : '')); }
break;
}
case 'addNote': {
var n = addNoteAt(v.currentTime, '');
if (n) { switchTab('notes'); toast('已收藏 ' + Util.fmtTime(v.currentTime) + (maybeFocus('notes', n.id) ? ' · 可直接输入,Esc 返回快捷键' : '')); }
break;
}
case 'togglePanel':
setCollapsed(!isCollapsed());
break;
case 'seekBack':
v.currentTime = Math.max(0, (v.currentTime || 0) - CONFIG.seekStep);
renderClock();
break;
case 'seekFwd':
v.currentTime = Math.min(isFinite(v.duration) && v.duration > 0 ? v.duration : 1e9,
(v.currentTime || 0) + CONFIG.seekStep);
renderClock();
break;
case 'volumeUp': setVolume((v.volume || 0) + CONFIG.volumeStep); break;
case 'volumeDown': setVolume((v.volume || 0) - CONFIG.volumeStep); break;
case 'speedDown': stepSpeed(-1); break;
case 'speedUp': stepSpeed(1); break;
case 'speedReset': setSpeed(1, true); break;
default: handled = false;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
}
}
function unbindVideo() {
var b = state.bound;
if (!b) return;
var v = b.video;
v.removeEventListener('timeupdate', b.onTime);
v.removeEventListener('loadedmetadata', b.onMeta);
v.removeEventListener('durationchange', b.onMeta);
v.removeEventListener('ratechange', b.onRate);
v.removeEventListener('play', b.onMeta);
state.bound = null;
}
function bindVideo(video, force) {
if (!video) return;
if (!force && state.video === video && doc.contains(video)) return;
unbindVideo();
unstretchVideo();
teardownOverlay();
state.video = video;
state.videoId = computeVideoId();
state.markers = store.loadMarkers(state.videoId);
state.notes = store.loadNotes(state.videoId);
state.urlSnapshot = window.location.href;
var onTime = function () {
renderClock();
highlightActiveMarker();
refreshStripGeometry();
};
var onMeta = function () {
renderStrip();
renderClock();
syncOverlayRect();
applyStripGeometry();
};
var onRate = function () { syncSpeedSelect(); };
video.addEventListener('timeupdate', onTime);
video.addEventListener('loadedmetadata', onMeta);
video.addEventListener('durationchange', onMeta);
video.addEventListener('ratechange', onRate);
video.addEventListener('play', onMeta);
state.bound = { video: video, onTime: onTime, onMeta: onMeta, onRate: onRate };
buildPanel();
attachOverlay();
renderLists();
renderClock();
renderVideoId();
applyPanelPos();
switchTab(state.prefs.tab === 'notes' ? 'notes' : 'markers');
syncSpeedSelect();
if (CONFIG.rememberSpeed && typeof state.prefs.speed === 'number' && state.prefs.speed > 0) {
try { video.playbackRate = state.prefs.speed; } catch (e) { }
syncSpeedSelect();
}
log('bound video', state.videoId, state.keyMode, state.markers.length, state.notes.length);
}
function boot() {
state.prefs = loadPrefs();
state.keyMap = buildKeyMap();
injectStyle();
on(window, 'keydown', onKeyDown, true);
on(doc, 'fullscreenchange', onFullscreenChange, true);
on(doc, 'webkitfullscreenchange', onFullscreenChange, true);
on(doc, 'mousemove', function (e) {
state.lastMouse.x = e.clientX;
state.lastMouse.y = e.clientY;
refreshStripGeometry();
settleGeometry();
}, { passive: true, capture: true });
on(doc, 'transitionend', function (e) {
/* 控制条自己的淡入淡出结束 → 立刻同步标记可见性,别等 400ms 心跳 */
if (e.propertyName === 'opacity' || e.propertyName === 'visibility') syncStripVisibility();
var g = state.stripGeom;
if (!g || !g.el) return;
var t = e.target;
if (t === g.el || (t && t.contains && t.contains(g.el))) scheduleGeomApply();
}, true);
on(doc, 'mouseover', function () { settleGeometry(); }, { passive: true, capture: true });
on(doc, 'scroll', function () { syncOverlayRect(); }, { passive: true, capture: true });
on(window, 'resize', debounce(function () {
syncOverlayRect();
applyStripGeometry();
if (!state.prefs.panel) autoPlacePanel();
else setPanelPos(state.panel ? parseFloat(state.panel.style.left) || 0 : 0,
state.panel ? parseFloat(state.panel.style.top) || 0 : 0);
}, 200));
var mo = new MutationObserver(debounce(function () {
var v = pickVideo();
if (v && v !== state.video) bindVideo(v, true);
}, 300));
try {
mo.observe(doc.documentElement, { childList: true, subtree: true });
} catch (e) { }
setInterval(function () {
var v = pickVideo();
if (v && v !== state.video) bindVideo(v, true);
if (window.location.href !== state.urlSnapshot) {
state.urlSnapshot = window.location.href;
var v2 = pickVideo();
if (v2) bindVideo(v2, true);
}
}, 1500);
if (!state.geomTimer) state.geomTimer = setInterval(geometryHeartbeat, CONFIG.geomHeartbeatMs);
var first = pickVideo();
if (first) bindVideo(first, true);
log('booted v' + VERSION);
}
function injectStyle() {
var css = [
'#htknow-enh-panel,#htknow-enh-panel *{box-sizing:border-box!important;}',
'#htknow-enh-panel{position:fixed!important;z-index:2147483000!important;width:320px!important;',
'max-height:min(72vh,620px)!important;display:flex!important;flex-direction:column!important;',
'background:rgba(24,26,32,.96)!important;color:#e8eaf0!important;border:1px solid rgba(255,255,255,.14)!important;',
'border-radius:10px!important;box-shadow:0 10px 34px rgba(0,0,0,.45)!important;',
'font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif!important;',
'text-align:left!important;overflow:hidden!important;}',
'#htknow-enh-panel.is-collapsed{max-height:40px!important;width:220px!important;}',
'#htknow-enh-panel.is-collapsed .htknow-enh-tools,#htknow-enh-panel.is-collapsed .htknow-enh-row,',
'#htknow-enh-panel.is-collapsed .htknow-enh-tabs,#htknow-enh-panel.is-collapsed .htknow-enh-body,',
'#htknow-enh-panel.is-collapsed .htknow-enh-foot{display:none!important;}',
'.htknow-enh-head{display:flex!important;align-items:center!important;gap:6px!important;padding:7px 10px!important;',
'background:rgba(255,255,255,.06)!important;cursor:move!important;user-select:none!important;}',
'.htknow-enh-logo{color:#6ba3ff!important;font-size:12px!important;}',
'.htknow-enh-title{font-weight:600!important;font-size:13px!important;white-space:nowrap!important;}',
'.htknow-enh-clock{font-variant-numeric:tabular-nums!important;color:#9aa3b2!important;font-size:12px!important;white-space:nowrap!important;}',
'.htknow-enh-grow{flex:1 1 auto!important;}',
'.htknow-enh-hbtn{background:transparent!important;border:0!important;color:#c8cddb!important;font-size:15px!important;',
'line-height:18px!important;padding:4px 8px!important;border-radius:6px!important;cursor:pointer!important;',
'min-width:26px!important;min-height:26px!important;}',
'.htknow-enh-hbtn:hover{background:rgba(255,255,255,.14)!important;color:#fff!important;}',
'.htknow-enh-tools{display:flex!important;gap:6px!important;padding:8px 10px 0!important;flex-wrap:wrap!important;}',
'.htknow-enh-btn{flex:1 1 auto!important;min-width:66px!important;padding:6px 8px!important;border-radius:7px!important;',
'border:1px solid rgba(79,140,255,.5)!important;background:rgba(79,140,255,.18)!important;color:#dfe6ff!important;',
'font-size:12px!important;font-weight:500!important;cursor:pointer!important;font-family:inherit!important;}',
'.htknow-enh-btn:hover{background:rgba(79,140,255,.34)!important;color:#fff!important;}',
'.htknow-enh-btn.is-ghost{border-color:rgba(255,255,255,.2)!important;background:rgba(255,255,255,.06)!important;color:#c8cddb!important;flex:0 0 auto!important;}',
'.htknow-enh-btn.is-ghost:hover{background:rgba(255,255,255,.16)!important;}',
'.htknow-enh-row{display:flex!important;align-items:center!important;gap:6px!important;padding:8px 10px 0!important;}',
'.htknow-enh-label{color:#9aa3b2!important;font-size:12px!important;}',
'.htknow-enh-select{background:rgba(255,255,255,.08)!important;color:#e8eaf0!important;border:1px solid rgba(255,255,255,.18)!important;',
'border-radius:6px!important;padding:4px 6px!important;font-size:12px!important;font-family:inherit!important;',
'min-height:26px!important;}',
'.htknow-enh-vid{margin-left:auto!important;color:#939bb0!important;font-size:12px!important;overflow:hidden!important;',
'text-overflow:ellipsis!important;white-space:nowrap!important;max-width:120px!important;}',
'.htknow-enh-tabs{display:flex!important;gap:4px!important;padding:8px 10px 0!important;border-bottom:1px solid rgba(255,255,255,.1)!important;}',
'.htknow-enh-tab{background:transparent!important;border:0!important;border-bottom:2px solid transparent!important;color:#9aa3b2!important;',
'padding:5px 8px!important;font-size:12px!important;cursor:pointer!important;font-family:inherit!important;}',
'.htknow-enh-tab i{font-style:normal!important;color:#939bb0!important;font-size:12px!important;}',
'.htknow-enh-tab.is-active{color:#fff!important;border-bottom-color:#4f8cff!important;}',
'.htknow-enh-body{flex:1 1 auto!important;overflow:auto!important;padding:6px 8px 8px!important;min-height:60px!important;}',
'.htknow-enh-pane{display:none!important;}',
'.htknow-enh-pane.is-active{display:block!important;}',
'.htknow-enh-list{display:flex!important;flex-direction:column!important;gap:6px!important;}',
'.htknow-enh-empty{color:#7b8397!important;font-size:12px!important;line-height:1.7!important;padding:10px 6px!important;text-align:center!important;}',
'.htknow-enh-empty b{color:#9fb6ff!important;}',
'.htknow-enh-item{display:flex!important;align-items:center!important;gap:6px!important;background:rgba(255,255,255,.05)!important;',
'border:1px solid rgba(255,255,255,.08)!important;border-radius:7px!important;padding:5px 6px!important;}',
'.htknow-enh-item.is-note{display:block!important;}',
'.htknow-enh-star{display:inline-flex!important;align-items:center!important;justify-content:center!important;',
'flex:0 0 auto!important;width:16px!important;height:16px!important;color:#ffcc33!important;',
'font:700 13px/1 "Segoe UI Symbol","Microsoft YaHei",sans-serif!important;}',
'.htknow-enh-item-head{display:flex!important;align-items:center!important;gap:6px!important;margin-bottom:4px!important;}',
'.htknow-enh-time{font-variant-numeric:tabular-nums!important;background:rgba(79,140,255,.2)!important;color:#cfe0ff!important;',
'border:0!important;border-radius:5px!important;padding:4px 7px!important;font-size:12px!important;line-height:16px!important;',
'cursor:pointer!important;font-family:inherit!important;white-space:nowrap!important;}',
'.htknow-enh-time:hover{background:rgba(79,140,255,.42)!important;color:#fff!important;}',
'.htknow-enh-input,.htknow-enh-textarea{flex:1 1 auto!important;width:100%!important;background:rgba(0,0,0,.28)!important;',
'border:1px solid rgba(255,255,255,.12)!important;border-radius:6px!important;color:#e8eaf0!important;',
'padding:4px 6px!important;font-size:12px!important;font-family:inherit!important;resize:vertical!important;outline:none!important;}',
'.htknow-enh-input:focus,.htknow-enh-textarea:focus{border-color:#4f8cff!important;background:rgba(0,0,0,.42)!important;}',
'.htknow-enh-x{background:transparent!important;border:0!important;color:#a6aec4!important;font-size:15px!important;line-height:20px!important;',
'padding:2px 6px!important;border-radius:6px!important;cursor:pointer!important;flex:0 0 auto!important;',
'min-width:24px!important;min-height:24px!important;}',
'.htknow-enh-x:hover{background:rgba(255,80,80,.24)!important;color:#ffb3b3!important;}',
'.htknow-enh-foot{padding:5px 10px 7px!important;color:#939bb0!important;font-size:12px!important;border-top:1px solid rgba(255,255,255,.08)!important;}',
'#htknow-enh-panel button:focus-visible,#htknow-enh-panel select:focus-visible,',
'#htknow-enh-panel [data-act]:focus-visible{outline:2px solid #6ba3ff!important;outline-offset:1px!important;}',
'#htknow-enh-panel input:focus-visible,#htknow-enh-panel textarea:focus-visible{outline:2px solid #6ba3ff!important;outline-offset:0!important;}',
'#htknow-enh-overlay{position:absolute!important;left:0!important;top:0!important;right:0!important;bottom:0!important;',
'pointer-events:none!important;z-index:2147482000!important;}',
'#htknow-enh-overlay.htknow-enh-overlay-fix{position:fixed!important;right:auto!important;bottom:auto!important;}',
'.htknow-enh-strip{position:absolute!important;left:0!important;right:0!important;bottom:0!important;pointer-events:none!important;}',
'.htknow-enh-marker{position:absolute!important;top:0!important;bottom:0!important;width:14px!important;margin-left:-7px!important;',
'pointer-events:auto!important;cursor:pointer!important;display:flex!important;justify-content:center!important;align-items:center!important;',
'opacity:1!important;transition:opacity .18s!important;}',
'.htknow-enh-marker-dot{font:700 13px/1 "Segoe UI Symbol","Microsoft YaHei",sans-serif!important;',
'color:#ffcc33!important;text-shadow:0 1px 3px rgba(0,0,0,.85)!important;',
'transform:translateY(-1px)!important;transition:transform .12s!important;}',
'.htknow-enh-marker.is-note .htknow-enh-marker-dot{font-size:14px!important;}',
'.htknow-enh-marker:hover .htknow-enh-marker-dot{transform:translateY(-1px) scale(1.35)!important;}',
'.htknow-enh-marker.is-active .htknow-enh-marker-dot{color:#ffe28a!important;}',
/* 站点控制条隐藏时(display:none / opacity:0 / visibility:hidden)标记一起藏起来 */
'.htknow-enh-strip.is-bar-hidden .htknow-enh-marker{opacity:0!important;pointer-events:none!important;}',
'#htknow-enh-toast{position:fixed!important;left:50%!important;bottom:56px!important;transform:translateX(-50%) translateY(12px)!important;',
'z-index:2147483600!important;background:rgba(24,26,32,.96)!important;color:#e8eaf0!important;border:1px solid rgba(255,255,255,.16)!important;',
'border-radius:8px!important;padding:8px 14px!important;font:13px/1.5 -apple-system,"Microsoft YaHei",sans-serif!important;',
'box-shadow:0 6px 20px rgba(0,0,0,.45)!important;opacity:0!important;pointer-events:none!important;transition:opacity .18s,transform .18s!important;',
'max-width:min(78vw,460px)!important;}',
'#htknow-enh-toast.is-show{opacity:1!important;transform:translateX(-50%) translateY(0)!important;}',
'#htknow-enh-toast.is-error{border-color:rgba(255,90,90,.55)!important;color:#ffd6d6!important;}'
].join('\n');
var s = doc.createElement('style');
s.id = 'htknow-enh-style';
s.textContent = css;
(doc.head || doc.documentElement).appendChild(s);
}
try {
boot();
} catch (e) {
try { console.error('[htknow-enh] 启动失败:', e); } catch (e2) { }
}
})();