Add star-player matches tab and watchlist-driven fetch.

Ship top-level #/matches with pro watchlist defaults, bump site to 0.5.71, and document the flow in AGENTS/README.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 16:03:45 +08:00
co-authored by Cursor
parent 09bd5aabb3
commit d9c7b4b7b1
11 changed files with 1073 additions and 130 deletions
+576 -79
View File
@@ -71,7 +71,7 @@ function renderPatchesSiteVersion() {
const state = {
data: null,
page: "heroes", // heroes | rankings | streamers | trends | mechanics | items | patches
page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches
selectedKey: null,
selectedItemKey: null,
/** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */
@@ -86,6 +86,8 @@ const state = {
selectedPatch: null,
/** Immortal leaderboard region: china | europe | americas | se_asia */
rankingRegion: "china",
/** Top-level matches page: OpenDota account_id string (null = all stars) */
matchesPlayerId: null,
/** Top-level 走势 page medal bracket */
trendsBracket: "legend",
/** Sort key for trends board: wr_end | pr_end */
@@ -388,6 +390,7 @@ const BRACKET_RANK_ICON = {
/** Recent matches tab: newest N ladder+league games. */
const MATCHES_DISPLAY_LIMIT = 10;
const PRO_MATCHES_PAGE_LIMIT = 60;
function heroStatsPack() {
return (
@@ -1202,7 +1205,7 @@ function appendMatchAbilityIcon(list, heroKey, abilityKey, level) {
list.appendChild(el);
}
function buildMatchCard(heroKey, row) {
function buildMatchCard(heroKey, row, opts = {}) {
const card = document.createElement("article");
card.className = "match-card" + (row.won ? " is-win" : " is-loss");
@@ -1214,6 +1217,37 @@ function buildMatchCard(heroKey, row) {
result.textContent = row.won ? "胜" : "负";
head.appendChild(result);
if (opts.showHero) {
const hKey = heroKey || row.hero_key || null;
const hero = hKey ? heroByKey(hKey) : null;
const heroBtn = document.createElement(hKey ? "button" : "span");
heroBtn.className = "match-hero";
heroBtn.type = hKey ? "button" : undefined;
const himg = document.createElement("img");
himg.className = "match-hero-icon";
himg.alt = hero?.name_loc || hKey || "";
himg.loading = "lazy";
if (hKey) himg.src = portraitSrc(hKey);
heroBtn.appendChild(himg);
const hname = document.createElement("span");
hname.className = "match-hero-name";
hname.textContent = hero?.name_loc || hKey || "未知英雄";
heroBtn.appendChild(hname);
if (hKey) {
heroBtn.title = `查看 ${hero?.name_loc || hKey}`;
heroBtn.addEventListener("click", () => {
state.page = "heroes";
state.selectedKey = hKey;
state.detailTab = "matches";
state.selectedItemKey = null;
state.inspect = null;
syncStateToUrl();
render();
});
}
head.appendChild(heroBtn);
}
const player = document.createElement("div");
player.className = "match-player";
@@ -1361,11 +1395,11 @@ function buildMatchCard(heroKey, row) {
return card;
}
function buildMatchList(heroKey, rows) {
function buildMatchList(heroKey, rows, opts = {}) {
const list = document.createElement("div");
list.className = "match-list";
for (const row of rows) {
list.appendChild(buildMatchCard(heroKey, row));
list.appendChild(buildMatchCard(heroKey || row.hero_key, row, opts));
}
return list;
}
@@ -1516,7 +1550,15 @@ function stratzMatchupPack() {
state.data?.stratz_matchup_tops || {
by_hero: {},
fetched_at: null,
started_at: null,
finished_at: null,
attribution: "https://stratz.com",
scope: {
kind: "global_aggregate",
label_zh: "全局聚合(未按段位 / 分路 / 周过滤)",
},
cross_source: null,
stats: {},
}
);
}
@@ -1730,8 +1772,7 @@ function buildStatsPanel(heroKey) {
if (!stratzCell && !odotaCell) {
const miss = document.createElement("div");
miss.className = "detail-muted";
miss.textContent =
"暂无走势数据";
miss.textContent = "暂无走势数据";
block.appendChild(miss);
return block;
}
@@ -1820,6 +1861,24 @@ function buildStatsPanel(heroKey) {
return block;
}
function formatMatchupWr(wr) {
if (wr == null || Number.isNaN(Number(wr))) return "—";
return `${(Number(wr) * 100).toFixed(1)}%`;
}
function formatMatchupFetched(iso) {
if (!iso) return "";
const s = String(iso);
return s.length >= 10 ? s.slice(0, 10) : s;
}
function matchupCrossLabel(cross) {
const status = cross?.status;
if (status === "agree") return "来源一致";
if (status === "conflict") return "来源分歧";
return null;
}
function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
const col = document.createElement("div");
col.className = "matchup-col";
@@ -1860,20 +1919,52 @@ function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
img.alt = hero?.name_loc || peerKey || "";
img.loading = "lazy";
img.decoding = "async";
const mid = document.createElement("div");
mid.className = "matchup-mid";
const name = document.createElement("span");
name.className = "matchup-name";
name.textContent = hero?.name_loc || peerKey || `#${row.hero_id}`;
const meta = document.createElement("span");
meta.className = "matchup-meta detail-muted";
const wrText = formatMatchupWr(row.wr);
const crossLab = matchupCrossLabel(row.cross);
meta.textContent = [
`胜率 ${wrText}`,
`${formatPickCount(row.games)}`,
crossLab,
]
.filter(Boolean)
.join(" · ");
mid.appendChild(name);
mid.appendChild(meta);
const score = document.createElement("span");
score.className = "matchup-score";
score.textContent = formatAdvantage(row[scoreKey]);
score.title = `${scoreLabel} ${formatAdvantage(row[scoreKey])} · ${formatPickCount(row.games)}`;
const games = document.createElement("span");
games.className = "matchup-games detail-muted";
games.textContent = formatPickCount(row.games);
const crossStatus = row.cross?.status;
if (crossStatus === "agree") score.classList.add("is-agree");
if (crossStatus === "conflict") score.classList.add("is-conflict");
const odotaTip =
row.cross && row.cross.opendota_games != null
? ` · OpenDota 基线差 ${
row.cross.opendota_adv == null
? "—"
: formatAdvantage(row.cross.opendota_adv * 100)
}pp / ${formatPickCount(row.cross.opendota_games)}`
: "";
score.title = [
`STRATZ 相对优势 ${formatAdvantage(row[scoreKey])}(非胜率百分点)`,
`对局胜率 ${wrText}`,
`${formatPickCount(row.games)}`,
crossLab ? `交叉:${crossLab}` : "",
odotaTip,
]
.filter(Boolean)
.join(" · ");
item.appendChild(img);
item.appendChild(name);
item.appendChild(mid);
item.appendChild(score);
item.appendChild(games);
list.appendChild(item);
}
col.appendChild(list);
@@ -1885,6 +1976,7 @@ function buildMatchupsPanel(heroKey) {
block.className = "detail-matchups-panel detail-tab-panel";
block.setAttribute("aria-label", "数据对位");
const pack = stratzMatchupPack();
const cell = stratzMatchupsFor(heroKey);
if (!cell) {
const miss = document.createElement("div");
@@ -1897,16 +1989,52 @@ function buildMatchupsPanel(heroKey) {
const grid = document.createElement("div");
grid.className = "matchup-grid";
grid.appendChild(
buildMatchupColumn("克制", cell.counters, "advantage", "advantage")
buildMatchupColumn("克制", cell.counters, "advantage", "STRATZ 相对优势")
);
grid.appendChild(
buildMatchupColumn("被克", cell.countered, "advantage", "劣势")
buildMatchupColumn("被克", cell.countered, "advantage", "STRATZ 相对劣势")
);
grid.appendChild(
buildMatchupColumn("搭档", cell.synergies, "synergy", "synergy")
buildMatchupColumn("搭档", cell.synergies, "synergy", "STRATZ synergy")
);
block.appendChild(grid);
const notes = document.createElement("div");
notes.className = "matchup-notes detail-muted";
const hintBits = [
"数字是相对表现(综合胜率与自身强弱),不是直接胜率;也不分段位、分路,和「走势」页不是同一套数据。",
];
const cross = pack.cross_source;
if (cross && cross.available === false) {
hintBits.push("暂无其他数据源可对照,交叉结论仅供参考。");
} else if (cross && cross.available) {
hintBits.push(
"「来源一致 / 分歧」表示是否与 OpenDota 公开统计方向相同,仅作对照。"
);
}
const hint = document.createElement("p");
hint.className = "matchup-note-line";
hint.textContent = hintBits.join(" ");
notes.appendChild(hint);
const stale = cell.stale === true;
if (stale) {
const line = document.createElement("p");
line.className = "matchup-note-line is-stale";
line.textContent = "本轮更新失败,当前仍是上一版数据,可能已过时。";
notes.appendChild(line);
}
const fetched = formatMatchupFetched(cell.fetched_at || pack.fetched_at);
if (fetched) {
const time = document.createElement("p");
time.className = "matchup-note-time";
time.textContent = `更新于 ${fetched}`;
notes.appendChild(time);
}
block.appendChild(notes);
return block;
}
@@ -3368,6 +3496,157 @@ function renderRankings() {
</table>`;
}
function proMatchesData() {
return (
(state.data && state.data.pro_matches) || {
meta: {},
items: {},
pros: {},
by_pro: {},
by_hero: {},
}
);
}
function proPlayerOptions() {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const prosMeta = pack.pros || {};
const rows = [];
for (const [sid, cell] of Object.entries(byPro)) {
if (!cell || typeof cell !== "object") continue;
const matches = Array.isArray(cell.matches) ? cell.matches : [];
if (!matches.length) continue;
const meta = prosMeta[sid] || {};
const name =
cell.name ||
meta.name ||
(matches[0] && matchPlayerDisplayName(matches[0])) ||
sid;
const team = cell.team_tag || meta.team_tag || "";
rows.push({
account_id: String(cell.account_id || sid),
name,
team_tag: team,
match_count: matches.length,
});
}
rows.sort(
(a, b) =>
String(a.name).localeCompare(String(b.name), "zh") ||
String(a.account_id).localeCompare(String(b.account_id))
);
return rows;
}
function proMatchRows(playerId) {
const pack = proMatchesData();
const byPro = pack.by_pro || {};
const out = [];
const seen = new Set();
const want = playerId ? String(playerId) : null;
for (const [sid, cell] of Object.entries(byPro)) {
if (!cell || typeof cell !== "object") continue;
if (want && String(cell.account_id || sid) !== want) continue;
const matches = Array.isArray(cell.matches) ? cell.matches : [];
for (const row of matches) {
if (!row || typeof row !== "object") continue;
const mid = Number(row.match_id);
if (!mid || seen.has(mid)) continue;
seen.add(mid);
out.push({
...row,
account_id: row.account_id || cell.account_id || Number(sid) || null,
name: row.name || cell.name || null,
display_name:
row.display_name ||
row.name ||
cell.name ||
row.personaname ||
null,
hero_key: row.hero_key || null,
});
}
}
out.sort(
(a, b) => (Number(b.start_time) || 0) - (Number(a.start_time) || 0)
);
return out;
}
function renderMatchesPage() {
const body = $("#matches-body");
const playersEl = $("#matches-players");
const sub = $("#matches-sub");
if (!body) return;
const options = proPlayerOptions();
const hasData = options.length > 0;
if (playersEl) {
if (!hasData) {
playersEl.innerHTML = "";
} else {
const chips = [
`<button type="button" class="rankings-region-btn${
!state.matchesPlayerId ? " active" : ""
}" data-player="" role="tab" aria-selected="${!state.matchesPlayerId}">全部</button>`,
];
for (const p of options) {
const active = state.matchesPlayerId === p.account_id ? " active" : "";
const label = p.team_tag
? `${escapeHtml(p.name)} · ${escapeHtml(p.team_tag)}`
: escapeHtml(p.name);
chips.push(
`<button type="button" class="rankings-region-btn${active}" data-player="${escapeHtml(
p.account_id
)}" role="tab" aria-selected="${
state.matchesPlayerId === p.account_id
}">${label}</button>`
);
}
playersEl.innerHTML = chips.join("");
playersEl.querySelectorAll("[data-player]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-player") || "";
state.matchesPlayerId = id || null;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
if (board) board.scrollTop = 0;
});
});
}
}
if (!hasData) {
body.innerHTML =
'<div class="rankings-empty">暂无比赛数据(请运行 python web/fetch_pro_matches.py</div>';
if (sub) sub.textContent = "";
return;
}
if (
state.matchesPlayerId &&
!options.some((p) => p.account_id === state.matchesPlayerId)
) {
state.matchesPlayerId = null;
}
const rows = proMatchRows(state.matchesPlayerId).slice(
0,
PRO_MATCHES_PAGE_LIMIT
);
if (sub) sub.textContent = "";
if (!rows.length) {
body.innerHTML = '<div class="rankings-empty">该选手暂无近期比赛</div>';
return;
}
body.replaceChildren(buildMatchList(null, rows, { showHero: true }));
}
function streamersData() {
return (
(state.data && state.data.streamers) || {
@@ -3407,6 +3686,24 @@ function streamerVideoSrc(row) {
return assetUrl(`streamer-video/${encodeURIComponent(base)}`);
}
/** Poster next to the clip: foo.mp4 → foo.jpg (optional override `video_poster`). */
function streamerVideoPosterSrc(row) {
const explicit = row && row.video_poster;
if (explicit && typeof explicit === "string") {
const base = explicit.replace(/^.*[\\/]/, "");
if (base && !base.startsWith("_")) {
return assetUrl(`streamer-video/${encodeURIComponent(base)}`);
}
}
const v = row && row.video;
if (!v || typeof v !== "string") return "";
const base = v.replace(/^.*[\\/]/, "");
if (!base || base.startsWith("_")) return "";
const stem = base.replace(/\.(mp4|webm)$/i, "");
if (!stem) return "";
return assetUrl(`streamer-video/${encodeURIComponent(`${stem}.jpg`)}`);
}
/** Parse optional streamers.json `video_aspect` ("1916/2317" or [w, h]). */
function parseStreamerVideoAspect(row) {
const raw = row && row.video_aspect;
@@ -3482,8 +3779,10 @@ function fitStreamerVideoFrame(video, frame, row) {
const fitRaw = row && row.video_fit;
const forceContain = fitRaw === "contain";
const forceCrop = fitRaw === "cover" || row?.video_crop === true;
frame.classList.remove("is-letterbox-crop");
frame.classList.remove("is-letterbox-crop", "is-pending");
delete frame.dataset.letterboxCrop;
frame.style.height = "";
frame.style.backgroundImage = "";
video.style.objectPosition = "";
frame.style.aspectRatio = `${w} / ${h}`;
frame.classList.toggle("is-landscape", w >= h);
@@ -3526,37 +3825,88 @@ function bindStreamerVideoSoundToggle(video) {
});
}
/**
* Viewport-tiered clip loading:
* - far: no src (cancel download)
* - near (~1 screen): attach src + preload=metadata
* - play (mid-screen band): preload=auto + play on canplay (not canplaythrough)
* At most one clip uses preload=auto so large mp4s do not contend.
*/
/** @type {Map<HTMLVideoElement, number>} mid-band visibility ratio */
const streamerVideoVisibility = new Map();
/** @type {Set<HTMLVideoElement>} within ~1 viewport of screen */
const streamerVideoNear = new Set();
let streamerVideoPlayObserver = null;
let streamerVideoNearObserver = null;
let streamerVideoReconcileQueued = false;
/** Attach / upgrade deferred clip URL. */
function ensureStreamerVideoSrc(video, { preload = "metadata" } = {}) {
if (!(video instanceof HTMLVideoElement)) return false;
const src = video.dataset.streamerSrc || "";
if (!src) return false;
if (video.dataset.streamerSrcAttached !== "1") {
video.src = src;
video.dataset.streamerSrcAttached = "1";
}
if (video.preload !== preload) video.preload = preload;
return true;
}
/** Drop src so the browser can cancel an in-flight fetch. */
function unloadStreamerVideoSrc(video) {
if (!(video instanceof HTMLVideoElement)) return;
if (video.dataset.streamerSrcAttached !== "1") return;
video.pause();
video.removeAttribute("src");
video.load();
delete video.dataset.streamerSrcAttached;
video.preload = "none";
const frame = video.closest(".streamer-video-frame");
if (frame && video.videoWidth === 0) frame.classList.add("is-pending");
}
function playStreamerVideo(video) {
if (!(video instanceof HTMLVideoElement)) return;
ensureStreamerVideoSrc(video, { preload: "auto" });
pauseOtherStreamerVideos(video);
// Scroll autoplay stays muted unless the user explicitly unmuted this clip.
if (!streamerVideoUserSoundOn(video)) {
video.muted = true;
video.setAttribute("muted", "");
}
const p = video.play();
if (p && typeof p.catch === "function") {
p.catch(() => {
video.muted = true;
video.setAttribute("muted", "");
delete video.dataset.streamerSoundOn;
const p2 = video.play();
if (p2 && typeof p2.catch === "function") p2.catch(() => {});
});
const tryPlay = () => {
const p = video.play();
if (p && typeof p.catch === "function") {
p.catch(() => {
video.muted = true;
video.setAttribute("muted", "");
delete video.dataset.streamerSoundOn;
const p2 = video.play();
if (p2 && typeof p2.catch === "function") p2.catch(() => {});
});
}
};
// HAVE_FUTURE_DATA — enough to start; do not wait for canplaythrough.
if (video.readyState >= 3) {
tryPlay();
return;
}
}
/** Play streamer clips when scrolled near viewport center (tall cards). */
let streamerVideoObserver = null;
/** @type {Map<HTMLVideoElement, number>} */
const streamerVideoVisibility = new Map();
function resetStreamerVideoObserver() {
if (streamerVideoObserver) {
streamerVideoObserver.disconnect();
streamerVideoObserver = null;
}
streamerVideoVisibility.clear();
if (video.dataset.streamerCanplayBound === "1") return;
video.dataset.streamerCanplayBound = "1";
const onReady = () => {
delete video.dataset.streamerCanplayBound;
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onErr);
if (pickActiveStreamerVideo() === video) tryPlay();
};
const onErr = () => {
delete video.dataset.streamerCanplayBound;
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onErr);
};
video.addEventListener("canplay", onReady);
video.addEventListener("error", onErr);
}
function pickActiveStreamerVideo() {
@@ -3571,7 +3921,6 @@ function pickActiveStreamerVideo() {
if (rect.height <= 0 || rect.width <= 0) continue;
const videoMid = (rect.top + rect.bottom) / 2;
const dist = Math.abs(videoMid - mid);
// Prefer more visible + closer to the visual center band.
const score = ratio * vh - dist;
if (score > bestScore) {
bestScore = score;
@@ -3581,47 +3930,102 @@ function pickActiveStreamerVideo() {
return best;
}
function syncStreamerVideoPlayback() {
/** Apply near / play / far tiers; only the active clip gets preload=auto. */
function reconcileStreamerVideoLoads() {
const active = pickActiveStreamerVideo();
document.querySelectorAll("video.streamer-video").forEach((v) => {
if (!(v instanceof HTMLVideoElement) || !v.isConnected) return;
if (!v.dataset.streamerSrc) return;
const near = streamerVideoNear.has(v) || v === active;
if (v === active) {
ensureStreamerVideoSrc(v, { preload: "auto" });
if (v.paused) playStreamerVideo(v);
} else if (!v.paused) {
v.pause();
return;
}
if (!v.paused) v.pause();
if (near) {
ensureStreamerVideoSrc(v, { preload: "metadata" });
} else {
unloadStreamerVideoSrc(v);
}
});
}
function ensureStreamerVideoObserver() {
if (streamerVideoObserver || typeof IntersectionObserver !== "function") {
return streamerVideoObserver;
function queueReconcileStreamerVideoLoads() {
if (streamerVideoReconcileQueued) return;
streamerVideoReconcileQueued = true;
requestAnimationFrame(() => {
streamerVideoReconcileQueued = false;
reconcileStreamerVideoLoads();
});
}
function syncStreamerVideoPlayback() {
queueReconcileStreamerVideoLoads();
}
function resetStreamerVideoObserver() {
if (streamerVideoPlayObserver) {
streamerVideoPlayObserver.disconnect();
streamerVideoPlayObserver = null;
}
if (streamerVideoNearObserver) {
streamerVideoNearObserver.disconnect();
streamerVideoNearObserver = null;
}
streamerVideoVisibility.clear();
streamerVideoNear.clear();
streamerVideoReconcileQueued = false;
}
function ensureStreamerVideoObservers() {
if (typeof IntersectionObserver !== "function") return;
if (!streamerVideoPlayObserver) {
streamerVideoPlayObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target;
if (!(video instanceof HTMLVideoElement)) continue;
streamerVideoVisibility.set(
video,
entry.isIntersecting ? entry.intersectionRatio : 0,
);
}
queueReconcileStreamerVideoLoads();
},
{
root: null,
rootMargin: "-12% 0px -32% 0px",
threshold: [0, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1],
},
);
}
if (!streamerVideoNearObserver) {
streamerVideoNearObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target;
if (!(video instanceof HTMLVideoElement)) continue;
if (entry.isIntersecting) streamerVideoNear.add(video);
else streamerVideoNear.delete(video);
}
queueReconcileStreamerVideoLoads();
},
{
// ~1 viewport above/below: warm metadata only, no full download.
root: null,
rootMargin: "100% 0px 100% 0px",
threshold: 0,
},
);
}
streamerVideoObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const video = entry.target;
if (!(video instanceof HTMLVideoElement)) continue;
streamerVideoVisibility.set(
video,
entry.isIntersecting ? entry.intersectionRatio : 0
);
}
syncStreamerVideoPlayback();
},
{
// Shrink root to a mid-screen band so tall cards don't need 45% of video on screen.
root: null,
rootMargin: "-12% 0px -32% 0px",
threshold: [0, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1],
}
);
return streamerVideoObserver;
}
function observeStreamerVideo(video) {
bindStreamerVideoSoundToggle(video);
const obs = ensureStreamerVideoObserver();
if (obs) obs.observe(video);
ensureStreamerVideoObservers();
if (streamerVideoNearObserver) streamerVideoNearObserver.observe(video);
if (streamerVideoPlayObserver) streamerVideoPlayObserver.observe(video);
}
/** Compact Chinese count: 1234 / 1.2万 / 1.2亿 */
@@ -3705,11 +4109,15 @@ function buildStreamerHeroTags(heroes) {
const himg = document.createElement("img");
himg.src = portraitSrc(key);
himg.alt = (h && h.name_loc) || key;
himg.loading = "lazy";
// Eager + high: same priority as streamer avatars vs video preloads.
himg.loading = "eager";
himg.decoding = "async";
try {
himg.fetchPriority = "high";
} catch {
/* older engines */
}
chip.appendChild(himg);
const label = document.createElement("span");
label.textContent = (h && h.name_loc) || key;
chip.appendChild(label);
chip.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
@@ -3777,7 +4185,24 @@ function buildStreamerCard(row) {
img.className = "streamer-avatar";
img.src = avatarSrc;
img.alt = nick;
img.loading = "lazy";
// Eager + high: avatars must win over sequential video preloads on HTTP/1.1.
img.loading = "eager";
img.decoding = "async";
try {
img.fetchPriority = "high";
} catch {
/* older engines */
}
img.addEventListener(
"error",
() => {
const ph = document.createElement("div");
ph.className = "streamer-avatar streamer-avatar-fallback";
ph.textContent = (nick || "?").slice(0, 1);
img.replaceWith(ph);
},
{ once: true },
);
avatarInner.appendChild(img);
} else {
const ph = document.createElement("div");
@@ -3835,19 +4260,32 @@ function buildStreamerCard(row) {
const vwrap = document.createElement("div");
vwrap.className = "streamer-video-wrap";
const frame = document.createElement("div");
frame.className = "streamer-video-frame";
// Compact skeleton until metadata; avoid full-bleed 9:16 black void.
frame.className = "streamer-video-frame is-pending";
const earlyAspect = parseStreamerVideoAspect(row);
if (earlyAspect) {
frame.style.aspectRatio = `${earlyAspect.w} / ${earlyAspect.h}`;
frame.classList.toggle("is-landscape", earlyAspect.w >= earlyAspect.h);
}
const video = document.createElement("video");
video.className = "streamer-video";
video.src = videoSrc;
// Defer src until viewport near / play tier attaches it.
video.dataset.streamerSrc = videoSrc;
video.preload = "none";
video.loop = true;
video.controls = true;
video.playsInline = true;
video.preload = "auto";
video.setAttribute("controls", "");
video.setAttribute("playsinline", "");
// Autoplay muted; user unmutes via native controls when they want sound.
video.muted = true;
video.setAttribute("muted", "");
const posterSrc = streamerVideoPosterSrc(row);
if (posterSrc) {
video.poster = posterSrc;
frame.classList.add("has-poster");
frame.style.backgroundImage = `url("${posterSrc}")`;
}
if (row.video_title) video.setAttribute("aria-label", String(row.video_title));
// Canvas letterbox scan needs CORS when clips are served from OSS.
if (staticAssetBase()) video.crossOrigin = "anonymous";
@@ -3855,9 +4293,11 @@ function buildStreamerCard(row) {
video.addEventListener("loadedmetadata", fitFrameToVideo);
video.addEventListener("loadeddata", fitFrameToVideo);
video.addEventListener("canplay", fitFrameToVideo);
if (video.readyState >= 2) fitFrameToVideo();
else if (video.readyState >= 1) fitFrameToVideo();
video.addEventListener("error", () => vwrap.remove());
video.addEventListener("error", () => {
// Ignore empties before the viewport tier attaches src.
if (video.dataset.streamerSrcAttached !== "1") return;
vwrap.remove();
});
frame.appendChild(video);
vwrap.appendChild(frame);
observeStreamerVideo(video);
@@ -3881,9 +4321,49 @@ function buildStreamerCards(rows, { emptyText } = {}) {
for (const row of rows) {
wrap.appendChild(buildStreamerCard(row));
}
// Avatars + hero portraits first; then viewport-tier video attach.
waitStreamerImagesThenSyncVideos(wrap);
return wrap;
}
/** Prefer images before reconciling which clip to warm/play. */
function waitStreamerImagesThenSyncVideos(wrap) {
const imgs = [
...wrap.querySelectorAll("img.streamer-avatar"),
...wrap.querySelectorAll(".streamer-hero-chip img"),
];
const start = () => {
if (!wrap.isConnected) return;
queueReconcileStreamerVideoLoads();
};
if (!imgs.length) {
start();
return;
}
let settled = 0;
let started = false;
const kick = () => {
if (started) return;
started = true;
start();
};
const onOne = () => {
settled += 1;
if (settled >= imgs.length) kick();
};
for (const img of imgs) {
// complete + broken (naturalWidth 0) still counts as settled.
if (img.complete) {
onOne();
continue;
}
img.addEventListener("load", onOne, { once: true });
img.addEventListener("error", onOne, { once: true });
}
// Soft cap so a hung image cannot block video forever.
setTimeout(kick, 2500);
}
function buildHeroStreamersPanel(heroKey) {
const panel = document.createElement("div");
panel.className = "detail-streamers-panel streamers-center-wrap";
@@ -4536,6 +5016,7 @@ function setPage(page) {
if (
page !== "heroes" &&
page !== "rankings" &&
page !== "matches" &&
page !== "streamers" &&
page !== "trends" &&
page !== "mechanics" &&
@@ -4551,6 +5032,10 @@ function setPage(page) {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "matches") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "streamers") {
state.selectedKey = null;
state.selectedItemKey = null;
@@ -4583,6 +5068,7 @@ function syncChrome() {
const itemsTb = $("#items-toolbar");
const heroesView = $("#heroes-view");
const rankingsView = $("#rankings-view");
const matchesView = $("#matches-view");
const streamersView = $("#streamers-view");
const trendsView = $("#trends-view");
const mechanicsView = $("#mechanics-view");
@@ -4593,6 +5079,7 @@ function syncChrome() {
if (itemsTb) itemsTb.classList.toggle("hidden", state.page !== "items");
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers");
if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends");
if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics");
@@ -4609,6 +5096,8 @@ function render() {
renderDetail();
} else if (state.page === "rankings") {
renderRankings();
} else if (state.page === "matches") {
renderMatchesPage();
} else if (state.page === "streamers") {
renderStreamers();
} else if (state.page === "trends") {
@@ -4641,7 +5130,7 @@ function applyPatch(patch) {
// Page (default heroes on bad/missing).
if (
patch.page &&
["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page)
["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page)
) {
state.page = patch.page;
} else {
@@ -4699,6 +5188,14 @@ function applyPatch(patch) {
state.rankingRegion = lb.default_region || order[0] || "china";
}
}
// Star-player filter (matches page).
if (state.page === "matches") {
if (patch.matchesPlayerId && /^\d+$/.test(String(patch.matchesPlayerId))) {
state.matchesPlayerId = String(patch.matchesPlayerId);
} else {
state.matchesPlayerId = null;
}
}
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
state.selectedItemKey = patch.itemKey;