// ==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 = [ '
', ' ', ' 视频助手', ' --:-- / --:--', ' ', ' ', '
', '
', ' ', ' ', ' ', ' ', '
', '
', ' 倍速', ' ', ' ', '
', '
', ' ', ' ', '
', '
', '
', '
', '
', '
空格 播放/暂停 · F 全屏 · M 标记 · B 收藏 · ← → 快进退 · C 收起
', '' ].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 '
' + '' + '' + '' + '
'; }).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 + '' + '' + '' + '' + '
' + '' + '
'; }).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) { } } })();