This commit is contained in:
2026-09-29 04:35:35 +02:00
parent 73c0659d4a
commit 6c813143eb
51 changed files with 3708 additions and 743 deletions
+174 -8
View File
@@ -143,6 +143,12 @@ class Danmaku {
this.media.addEventListener('seeked', this._bound_onSeek, { passive: true });
this.media.addEventListener('pause', this._bound_onPause, { passive: true });
this.media.addEventListener('play', this._bound_onPlay, { passive: true });
// Comment markers on the progress bar need the duration
this._bound_onDuration = () => this._renderMarkers();
this._bound_onMarker = this._onMarkerClick.bind(this);
this.media.addEventListener('loadedmetadata', this._bound_onDuration, { passive: true });
this.media.addEventListener('durationchange', this._bound_onDuration, { passive: true });
}
/**
@@ -230,6 +236,7 @@ class Danmaku {
.map(c => ({
id: c.id || null,
text: this._prepareText(c.content),
raw: c.content,
username: c.display_name || c.username || '?',
color: c.username_color || null,
raw_time: (c.video_time != null) ? parseFloat(c.video_time) : null,
@@ -257,6 +264,7 @@ class Danmaku {
this._resetFiredState(this.media.currentTime);
this._lastTime = this.media.currentTime;
this._renderMarkers();
}
/**
@@ -335,6 +343,8 @@ class Danmaku {
id: comment.id || null,
video_time: t,
text: this._prepareText(comment.content),
raw: comment.content,
raw_time: (comment.video_time != null) ? parseFloat(comment.video_time) : null,
username: comment.display_name || comment.username || '?',
color: comment.username_color || null,
fired: true
@@ -350,6 +360,7 @@ class Danmaku {
const idx = this.items.findIndex(i => i.video_time > t);
if (idx === -1) this.items.push(item);
else this.items.splice(idx, 0, item);
this._renderMarkers();
}
/** Toggle danmaku on/off. */
@@ -357,6 +368,9 @@ class Danmaku {
this._enabled = !this._enabled;
localStorage.setItem('danmaku', this._enabled ? 'true' : 'false');
if (this.overlay) this.overlay.style.display = this._enabled ? '' : 'none';
// Comment markers on the progress bar belong to danmaku: hidden while it is off
if (this._markerLayer) this._markerLayer.style.display = this._enabled ? '' : 'none';
if (!this._enabled) this._hideMarkerPreview();
const sw = this.player.querySelector('#toggledanmaku');
if (sw) sw.classList.toggle('active', this._enabled);
@@ -482,6 +496,12 @@ class Danmaku {
this.media.removeEventListener('seeked', this._bound_onSeek);
this.media.removeEventListener('pause', this._bound_onPause);
this.media.removeEventListener('play', this._bound_onPlay);
this.media.removeEventListener('loadedmetadata', this._bound_onDuration);
this.media.removeEventListener('durationchange', this._bound_onDuration);
if (this._markerLayer && this._markerLayer.parentNode) this._markerLayer.parentNode.removeChild(this._markerLayer);
this._markerLayer = null;
if (this._markerTip && this._markerTip.parentNode) this._markerTip.parentNode.removeChild(this._markerTip);
this._markerTip = null;
if (this._bound_onEmojis) window.removeEventListener('f0ck:emojis_ready', this._bound_onEmojis);
if (this._bound_onTuningChange) window.removeEventListener('f0ck:danmaku_tuning_changed', this._bound_onTuningChange);
if (this._laneGuidesTimer) clearInterval(this._laneGuidesTimer);
@@ -633,6 +653,129 @@ class Danmaku {
}
}
// ── Progress-bar comment markers ─────────────────────────────────────────
// One marker per timeline comment on .v0ck_progress. Comments with their own timestamp are solid;
// the others got a random time on load and are drawn fainter. Clicking jumps there and shows the pill.
_renderMarkers() {
if (this._destroyed || this._synthClock) return;
const track = this.player.querySelector('.v0ck_progress');
if (!track) return;
const dur = this.media.duration;
const ok = isFinite(dur) && dur > 0 && this.items.length > 0;
let layer = this._markerLayer;
if (!ok) { if (layer) layer.textContent = ''; return; }
if (!layer || !layer.isConnected) {
layer = document.createElement('div');
layer.className = 'danmaku-markers';
if (!this._enabled) layer.style.display = 'none';
// Keep the bar's own scrub handlers (mousedown/pointerdown/click on the track) out of marker clicks
const stop = (e) => e.stopPropagation();
layer.addEventListener('pointerdown', stop);
layer.addEventListener('mousedown', stop);
layer.addEventListener('touchstart', stop, { passive: true });
layer.addEventListener('click', this._bound_onMarker);
layer.addEventListener('mouseover', (e) => {
const b = e.target.closest && e.target.closest('.danmaku-marker');
if (b) this._showMarkerPreview(b);
});
layer.addEventListener('mouseout', (e) => {
const to = e.relatedTarget;
if (!to || !to.closest || !to.closest('.danmaku-marker')) this._hideMarkerPreview();
});
track.appendChild(layer);
this._markerLayer = layer;
}
const fmt = (t) => {
const m = Math.floor(t / 60), sec = Math.floor(t % 60);
return m + ':' + String(sec).padStart(2, '0');
};
const frag = document.createDocumentFragment();
this.items.forEach((item, i) => {
if (!(item.video_time >= 0) || item.video_time > dur) return;
const b = document.createElement('button');
b.type = 'button';
b.tabIndex = -1;
b.className = 'danmaku-marker' + (item.raw_time == null ? ' is-random' : '');
b.style.left = (item.video_time / dur * 100) + '%';
b.dataset.idx = String(i);
const txt = String(item.raw || '').replace(/\s+/g, ' ').trim();
b.setAttribute('aria-label', fmt(item.video_time) + ' · ' + item.username + ': ' + (txt.length > 80 ? txt.slice(0, 80) + '…' : txt));
frag.appendChild(b);
});
this._hideMarkerPreview();
layer.textContent = '';
layer.appendChild(frag);
}
// Plain-text preview of a comment above its marker. Built with textContent only (no HTML from comments).
_showMarkerPreview(b) {
const item = this.items[+b.dataset.idx];
if (!item) return;
let tip = this._markerTip;
if (!tip || !tip.isConnected) {
tip = document.createElement('div');
tip.className = 'danmaku-marker-preview';
tip.innerHTML = '<div class="dmp-head"><span class="dmp-time"></span><span class="dmp-user"></span></div><div class="dmp-text"></div>';
this.player.appendChild(tip);
this._markerTip = tip;
}
const t = item.video_time;
tip.querySelector('.dmp-time').textContent = Math.floor(t / 60) + ':' + String(Math.floor(t % 60)).padStart(2, '0');
const user = tip.querySelector('.dmp-user');
user.textContent = item.username;
user.style.color = item.color || '';
// Same renderer as the flying pills: emojis, images/media embeds, greentext; spoiler and blur
// stay hidden. Builds DOM nodes (no HTML from the comment); size is capped by CSS.
const body = tip.querySelector('.dmp-text');
body.textContent = '';
const liveCache = (this._emojiCache && Object.keys(this._emojiCache).length > 0)
? this._emojiCache
: ((typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null);
if (liveCache && liveCache !== this._emojiCache) this._emojiCache = liveCache;
body.appendChild(this._renderContent(item.raw || ''));
if (!body.firstChild) body.textContent = '…';
// Reposition once images have their size
body.querySelectorAll('img').forEach(img => {
if (!img.complete) img.addEventListener('load', () => { if (this._markerTip === tip && tip.classList.contains('is-visible')) this._showMarkerPreview(b); }, { once: true });
});
// Position: centred over the marker, just above the progress bar, kept inside the player
const pr = this.player.getBoundingClientRect();
const mr = b.getBoundingClientRect();
tip.style.visibility = 'hidden';
tip.classList.add('is-visible');
const w = tip.offsetWidth;
let left = mr.left + mr.width / 2 - pr.left - w / 2;
left = Math.max(6, Math.min(left, pr.width - w - 6));
tip.style.left = left + 'px';
tip.style.bottom = (pr.bottom - mr.top + 8) + 'px';
tip.style.visibility = '';
}
_hideMarkerPreview() {
if (this._markerTip) this._markerTip.classList.remove('is-visible');
}
_onMarkerClick(e) {
const b = e.target.closest && e.target.closest('.danmaku-marker');
if (!b) return;
e.preventDefault();
e.stopPropagation();
const item = this.items[+b.dataset.idx];
if (!item) return;
// Land just past the comment: the seek reset then counts it as fired, so the timeline won't
// spawn it a second time; it is shown right here instead (also while paused)
const dur = this.media.duration;
const t = Math.min(isFinite(dur) ? dur : Infinity, item.video_time + LOOKAHEAD_SEC + 0.05);
try { this.media.currentTime = t; } catch (_) {}
item.fired = true;
this._hideMarkerPreview();
this._spawnPill(item.text, item.username, item.color, true);
}
_onPause() {
this._paused = true;
if (this._synthClock) this._synthClock.pause();
@@ -664,6 +807,15 @@ class Danmaku {
return;
}
// Forward jump (seek, marker click, scrubbing): the timeupdate at the new position arrives before
// 'seeked', and treating the gap as played would fire every comment in between. Resync instead,
// so only comments from here on appear. Normal playback advances ~0.25s per event (x playbackRate).
const maxStep = Math.max(1.5, 1.5 * (this.media.playbackRate || 1));
if (this.media.seeking || now > prev + maxStep) {
this._resetFiredState(now);
return;
}
const from = prev;
const to = now + LOOKAHEAD_SEC;
@@ -715,8 +867,9 @@ class Danmaku {
return best;
}
_spawnPill(text, username, color) {
if (!this.overlay || !this._enabled || this._paused) return;
/** @param {boolean} [force] show even while paused (marker click on the progress bar) */
_spawnPill(text, username, color, force = false) {
if (!this.overlay || !this._enabled || (this._paused && !force)) return;
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
const limit = Number(cfg.densityLimit) || 35;
@@ -932,11 +1085,20 @@ class Danmaku {
_prepareText(text) {
if (!text) return '';
// Idempotent: pills prepare the text on load and _renderContent prepares it again. Media already
// wrapped as \x04url\x05 is kept as one token; re-matching the URL inside it used to double-wrap
// it and leave a stray \x05 that browsers draw as a little control-character box behind the image.
const imgTokenUrls = [];
const emojiTokens = [];
let protected_ = text.replace(/:([a-z0-9_+\-]+):/gi, (match) => {
emojiTokens.push(match);
return `\x02${emojiTokens.length - 1}\x03`;
});
let protected_ = text
.replace(/\x04([^\x04\x05]+)\x05/g, (_, url) => {
imgTokenUrls.push(url);
return `\x04${imgTokenUrls.length - 1}\x05`;
})
.replace(/:([a-z0-9_+\-]+):/gi, (match) => {
emojiTokens.push(match);
return `\x02${emojiTokens.length - 1}\x03`;
});
const allowedHosts = Array.isArray(window.f0ckAllowedImages) ? window.f0ckAllowedImages : [];
const siteHost = window.location.hostname;
@@ -946,7 +1108,6 @@ class Danmaku {
return h === siteHost || allowedHosts.some(a => h === a || h.endsWith('.' + a));
} catch { return false; }
};
const imgTokenUrls = [];
protected_ = protected_
.replace(/\/c\/[a-f0-9]+\.(?:png|jpg|jpeg|gif|webp|svg|avif|mp4|webm|ogv|mov|mp3|ogg|wav|flac|aac|opus|m4a)(?:#gif)?/gi, (url) => {
imgTokenUrls.push(url);
@@ -967,9 +1128,14 @@ class Danmaku {
.replace(/\n{3,}/g, '\n\n')
.trim();
// Any other control characters (from the comment itself) would render as visible boxes; strays
// of the token markers too. Valid media tokens are parked on \x0E/\x0F (already stripped) meanwhile.
return protected_
.replace(/[\x00-\x01\x06-\x08\x0B-\x1F\x7F]/g, '')
.replace(/\x02(\d+)\x03/g, (_, i) => emojiTokens[+i] || '')
.replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? `\x04${imgTokenUrls[+i]}\x05` : '');
.replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? '\x0E' + i + '\x0F' : '')
.replace(/[\x02-\x05]/g, '')
.replace(/\x0E(\d+)\x0F/g, (_, i) => '\x04' + imgTokenUrls[+i] + '\x05');
}
}