(() => { const lifetime = 8; const windowMS = 30000; function plan(items, width, rows, fontSize, measure) { if (width <= 0 || rows < 1) return []; const lanes = Array(rows).fill(null), result = [], seen = new Set(); const valid = items.filter(item => item && typeof item.id === 'string' && item.id && typeof item.text === 'string' && item.text.trim() && Number.isFinite(item.timeMs) && item.timeMs >= 0); for (const item of valid.slice().sort((a, b) => a.timeMs - b.timeMs)) { if (seen.has(item.id)) continue; seen.add(item.id); const text = Array.from(item.text).slice(0, 100).join(''); const size = Math.ceil(measure ? measure(text) : Array.from(text).length * fontSize * 1.1) + 8; const start = item.timeMs / 1000; const lane = lanes.findIndex(previous => { if (!previous || start - previous.start >= lifetime) return true; const gap = start - previous.start; return (width + previous.size) * gap / lifetime - previous.size >= 24 && width - (width + size) * (lifetime - gap) / lifetime >= 24; }); if (lane < 0) continue; const entry = {id: item.id, text, start, lane, size}; lanes[lane] = entry; result.push(entry); } return result; } function frames(schedule, position, width) { return schedule.filter(item => position >= item.start && position < item.start + lifetime) .map(item => ({...item, x: width - (width + item.size) * (position - item.start) / lifetime})); } function createClock() { let media = 0, wall = 0, rate = 1, moving = false; const position = now => media + (moving ? Math.max(0, now - wall) * rate / 1000 : 0); return { position, sync(value, now, speed, running, force = false) { if (!Number.isFinite(value)) return; if (force || running !== moving || speed !== rate || Math.abs(value - position(now)) > .35) { media = value; wall = now; rate = speed; moving = running; } } }; } if (typeof module !== 'undefined' && module.exports) module.exports = {plan, frames, lifetime, createClock}; if (typeof document === 'undefined') return; const node = id => document.getElementById(id); const video = node('onlineVideo'), stage = node('playbackStage'), layer = node('playbackDanmaku'); const control = node('danmakuControl'), enabled = node('danmakuEnabled'), status = node('danmakuStatus'); let session = '', episode = 0, supported = false, ready = false, generation = 0; let pages = new Map(), failures = new Map(), schedule = [], elements = new Map(); let controller = null, pendingStart = -1, animation = 0, width = 0, fontSize = 18, rows = 3; let waiting = false; const clock = createClock(); const measure = document.createElement('canvas').getContext('2d'); try {enabled.checked = localStorage.getItem('juku-danmaku-enabled') !== 'false';} catch (_) {} function clearLayer() { for (const item of elements.values()) item.motion?.cancel(); layer.replaceChildren(); elements.clear(); } function moving() { return supported && enabled.checked && ready && !video.paused && !video.ended && !video.seeking && !waiting && !document.hidden; } function cancelRequest() { if (controller) controller.abort(); controller = null; pendingStart = -1; } function suspend() { ready = false; waiting = false; cancelAnimationFrame(animation); animation = 0; cancelRequest(); clearLayer(); } function setEpisode(id, index, canLoad) { suspend(); if (session !== id || episode !== index) { generation++; pages = new Map(); failures = new Map(); schedule = []; } session = id; episode = index; supported = Boolean(canLoad); control.hidden = !supported; status.hidden = !supported; status.textContent = supported && enabled.checked ? '等待播放' : ''; } function close() { setEpisode('', 0, false); if (document.fullscreenElement === stage) document.exitFullscreen().catch(() => {}); else if (document.webkitFullscreenElement === stage && document.webkitExitFullscreen) document.webkitExitFullscreen(); } function rebuild() { width = layer.clientWidth; fontSize = width < 600 ? 16 : 20; rows = Math.max(1, Math.min(4, Math.floor(layer.clientHeight * .55 / (fontSize * 1.5)))); layer.style.fontSize = fontSize + 'px'; if (measure) measure.font = '600 ' + fontSize + 'px sans-serif'; schedule = plan([...pages.values()].flatMap(page => page.items), width, rows, fontSize, measure ? text => measure.measureText(text).width : undefined); } function draw(now = performance.now(), sync = false) { if (!supported || !enabled.checked || !ready || video.seeking || document.hidden) return; const position = clock.position(now), running = moving(); const live = frames(schedule, position, width), active = new Set(live.map(item => item.id)); for (const [id, item] of elements) { if (!active.has(id)) {item.motion?.cancel(); item.node.remove(); elements.delete(id);} } for (const item of live) { let element = elements.get(item.id); const y = item.lane * fontSize * 1.5; if (element && (element.width !== width || element.size !== item.size || element.y !== y)) { element.motion?.cancel(); element.node.remove(); elements.delete(item.id); element = null; } if (!element) { const span = document.createElement('span'); span.textContent = item.text; span.className = 'danmaku-item'; layer.appendChild(span); const motion = span.animate ? span.animate([ {transform: 'translate3d(' + width + 'px,' + y + 'px,0)'}, {transform: 'translate3d(' + -item.size + 'px,' + y + 'px,0)'} ], {duration: lifetime * 1000, easing: 'linear', fill: 'both'}) : null; if (motion) {motion.pause(); motion.currentTime = (position - item.start) * 1000;} element = {node: span, motion, width, size: item.size, y}; elements.set(item.id, element); } if (element.motion) { const motion = element.motion, elapsed = (position - item.start) * 1000; if (motion.playbackRate !== video.playbackRate) motion.playbackRate = video.playbackRate; if ((sync && Math.abs(Number(motion.currentTime) - elapsed) > 200) || !running) motion.currentTime = elapsed; if (running && motion.playState !== 'running') motion.play(); else if (!running && motion.playState !== 'paused') motion.pause(); } else { element.node.style.transform = 'translate3d(' + item.x.toFixed(2) + 'px,' + y + 'px,0)'; } } } function refreshStatus() { if (!supported || !enabled.checked) {status.textContent = ''; return;} const count = [...pages.values()].reduce((sum, page) => sum + page.items.length, 0); status.textContent = count ? count + ' 条已载入' : pages.size ? '这段暂无弹幕' : '等待播放'; } async function loadWindow(start, duration) { const token = generation, currentSession = session, currentEpisode = episode; const requestController = new AbortController(); controller = requestController; pendingStart = start; if (!pages.size) status.textContent = '加载中…'; const timeout = setTimeout(() => requestController.abort(), 12000); try { const query = new URLSearchParams({session: currentSession, episode: String(currentEpisode), start: String(start), duration: String(duration)}); const response = await fetch('/api/ui/playback/danmaku?' + query, {signal: requestController.signal, credentials: 'same-origin', headers: {'Accept': 'application/json', ...window.JukuViewer?.headers()}}); if (!response.ok) throw new Error('弹幕暂不可用'); const page = await response.json(); if (token !== generation || controller !== requestController || requestController.signal.aborted) return; if (page.startMs !== start || !Array.isArray(page.items) || !Number.isFinite(page.nextMs) || page.nextMs <= start) throw new Error('弹幕数据无效'); page.items = page.items.filter(item => item && typeof item.id === 'string' && typeof item.text === 'string' && Number.isFinite(item.timeMs) && item.timeMs >= 0 && item.timeMs < duration).slice(0, 90); pages.set(start, page); failures.delete(start); const current = Math.floor(video.currentTime * 1000 / windowMS) * windowMS; const farthest = [...pages.keys()].sort((a, b) => Math.abs(a - current) - Math.abs(b - current)); for (const key of farthest.slice(6)) pages.delete(key); rebuild(); refreshStatus(); draw(); } catch (error) { if (token === generation && controller === requestController) { failures.set(start, Date.now() + 15000); status.textContent = '弹幕暂不可用'; } } finally { clearTimeout(timeout); if (controller === requestController) {controller = null; pendingStart = -1;} } } function ensureWindow() { if (!supported || !enabled.checked || !ready || video.seeking || document.hidden || !session || !Number.isFinite(video.duration) || video.duration <= 0) return; const position = Math.max(0, Math.floor(video.currentTime * 1000)); const duration = Math.min(86400000, Math.ceil(video.duration * 1000)); if (position >= duration) return; const current = Math.floor(position / windowMS) * windowMS; let wanted = current; if (pages.has(current)) { const next = pages.get(current).nextMs; if (position < next - 8000 || next >= duration || pages.has(next)) return; wanted = next; } if ((failures.get(wanted) || 0) > Date.now()) return; if (controller) { if (pendingStart === wanted || pages.has(current)) return; cancelRequest(); } loadWindow(wanted, duration); } function tick(now = performance.now()) { animation = 0; draw(now); if (moving()) animation = requestAnimationFrame(tick); } function update(force = false) { const now = performance.now(); clock.sync(video.currentTime, now, video.playbackRate, moving(), force); ensureWindow(); draw(now, true); if (moving()) { if (!animation) animation = requestAnimationFrame(tick); } else { cancelAnimationFrame(animation); animation = 0; } } video.addEventListener('loadedmetadata', () => {ready = true; waiting = false; rebuild(); update(true);}); video.addEventListener('playing', () => {ready = true; waiting = false; update(true);}); video.addEventListener('waiting', () => {waiting = true; update(true);}); video.addEventListener('timeupdate', () => update()); video.addEventListener('seeked', () => {waiting = false; update(true);}); for (const event of ['pause', 'ratechange']) video.addEventListener(event, () => update(true)); video.addEventListener('seeking', () => {cancelRequest(); clearLayer(); update(true);}); video.addEventListener('ended', suspend); enabled.addEventListener('change', () => { try {localStorage.setItem('juku-danmaku-enabled', String(enabled.checked));} catch (_) {} cancelRequest(); failures.clear(); if (!enabled.checked) {cancelAnimationFrame(animation); animation = 0; clearLayer();} refreshStatus(); update(true); }); document.addEventListener('visibilitychange', () => { if (document.hidden) { cancelAnimationFrame(animation); animation = 0; for (const item of elements.values()) item.motion?.pause(); } else update(true); }); if (window.ResizeObserver) new ResizeObserver(() => {rebuild(); draw(performance.now(), true);}).observe(layer); else window.addEventListener('resize', () => {rebuild(); draw(performance.now(), true);}); const fullscreen = node('playerFullscreenBtn'), exit = node('exitPlayerFullscreenBtn'); const supportsFullscreen = Boolean(stage.requestFullscreen || stage.webkitRequestFullscreen); fullscreen.hidden = !supportsFullscreen; if (supportsFullscreen) video.setAttribute('controlslist', 'nofullscreen'); fullscreen.addEventListener('click', async () => { try { if (stage.requestFullscreen) await stage.requestFullscreen(); else stage.webkitRequestFullscreen(); } catch (_) {status.textContent = '浏览器未允许全屏';} }); exit.addEventListener('click', () => { if (document.exitFullscreen) document.exitFullscreen().catch(() => {}); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); }); window.JukuPlaybackDanmaku = {setEpisode, suspend, close}; })();