gfds
This commit is contained in:
+174
-8
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user