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:
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"meta": {
|
||||
"note_zh": "手工维护的明星选手 OpenDota account_id;fetch_pro_matches.py 默认只拉此名单。队名/昵称可随转会过期,以 account_id 为准。",
|
||||
"source": "opendota/proPlayers",
|
||||
"updated_at": "2026-07-29"
|
||||
},
|
||||
"players": [
|
||||
{"account_id": 898754153, "name": "Ame", "team_tag": "XG", "region": "cn"},
|
||||
{"account_id": 173978074, "name": "NothingToSay", "team_tag": "XG", "region": "cn"},
|
||||
{"account_id": 94296097, "name": "xNova", "team_tag": "XG", "region": "cn"},
|
||||
{"account_id": 129958758, "name": "Xxs", "team_tag": "XG", "region": "cn"},
|
||||
{"account_id": 101695162, "name": "fy", "team_tag": "XG", "region": "cn"},
|
||||
{"account_id": 157475523, "name": "XinQ", "team_tag": "VG", "region": "cn"},
|
||||
{"account_id": 320252024, "name": "shiro", "team_tag": "VG", "region": "cn"},
|
||||
{"account_id": 148215639, "name": "Monet", "team_tag": "", "region": "cn"},
|
||||
{"account_id": 854671154, "name": "XM", "team_tag": "", "region": "cn"},
|
||||
{"account_id": 292921272, "name": "Wisper", "team_tag": "LGD", "region": "cn"},
|
||||
|
||||
{"account_id": 321580662, "name": "Yatoro", "team_tag": "TSpirit", "region": "eu"},
|
||||
{"account_id": 302214028, "name": "Collapse", "team_tag": "TSpirit", "region": "eu"},
|
||||
{"account_id": 106305042, "name": "Larl", "team_tag": "TSpirit", "region": "eu"},
|
||||
{"account_id": 113331514, "name": "Miposhka", "team_tag": "TSpirit", "region": "eu"},
|
||||
|
||||
{"account_id": 100058342, "name": "skiter", "team_tag": "FLCN", "region": "eu"},
|
||||
{"account_id": 183719386, "name": "AMMAR_THE_F", "team_tag": "FLCN", "region": "eu"},
|
||||
{"account_id": 898455820, "name": "Malr1ne", "team_tag": "FLCN", "region": "eu"},
|
||||
{"account_id": 10366616, "name": "Sneyking", "team_tag": "FLCN", "region": "eu"},
|
||||
|
||||
{"account_id": 201358612, "name": "Nisha", "team_tag": "Liquid", "region": "eu"},
|
||||
{"account_id": 77490514, "name": "Boxi", "team_tag": "Liquid", "region": "eu"},
|
||||
{"account_id": 16497807, "name": "tOfu", "team_tag": "Liquid", "region": "eu"},
|
||||
{"account_id": 54580962, "name": "Insania", "team_tag": "Liquid", "region": "eu"},
|
||||
|
||||
{"account_id": 93618577, "name": "bzm", "team_tag": "1w", "region": "eu"},
|
||||
{"account_id": 331855530, "name": "Pure", "team_tag": "1w", "region": "eu"},
|
||||
{"account_id": 86698277, "name": "33", "team_tag": "1w", "region": "eu"},
|
||||
{"account_id": 136829091, "name": "Whitemon", "team_tag": "1w", "region": "eu"},
|
||||
|
||||
{"account_id": 124801257, "name": "Nightfall", "team_tag": "Aurora", "region": "eu"},
|
||||
{"account_id": 858106446, "name": "kiyotaka", "team_tag": "Aurora", "region": "eu"},
|
||||
{"account_id": 480412663, "name": "gpk~", "team_tag": "BB", "region": "eu"},
|
||||
{"account_id": 196878136, "name": "Kataomi`", "team_tag": "BB", "region": "eu"},
|
||||
{"account_id": 1044002267, "name": "Satanic", "team_tag": "VSN", "region": "eu"},
|
||||
{"account_id": 127617979, "name": "Crystallis", "team_tag": "MOUZ", "region": "eu"},
|
||||
{"account_id": 431770905, "name": "TORONTOTOKYO", "team_tag": "OG", "region": "eu"},
|
||||
{"account_id": 111620041, "name": "SumaiL-", "team_tag": "NGX", "region": "eu"},
|
||||
{"account_id": 103735745, "name": "Saksa", "team_tag": "TY", "region": "eu"}
|
||||
]
|
||||
}
|
||||
@@ -50,7 +50,7 @@ from shared.paths import (
|
||||
|
||||
from serve_relations import WEB_DIR, build_payload
|
||||
|
||||
SITE_VERSION = "0.5.61"
|
||||
SITE_VERSION = "0.5.71"
|
||||
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||
|
||||
|
||||
@@ -87,9 +87,14 @@ def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
|
||||
n_vid = 0
|
||||
dst = out / "streamer-video"
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
for f in sorted(STREAMER_VIDEOS.glob("*.mp4")):
|
||||
for f in sorted(STREAMER_VIDEOS.iterdir()):
|
||||
if not f.is_file() or f.name.startswith("_"):
|
||||
continue
|
||||
name = f.name.lower()
|
||||
if not name.endswith(
|
||||
(".mp4", ".webm", ".jpg", ".jpeg", ".webp", ".png")
|
||||
):
|
||||
continue
|
||||
shutil.copy2(f, dst / f.name)
|
||||
n_vid += 1
|
||||
counts["streamer-video"] = n_vid
|
||||
|
||||
+154
-27
@@ -1,14 +1,18 @@
|
||||
"""Fetch recent league/tournament matches for OpenDota registered pros.
|
||||
|
||||
Pulls /proPlayers, then per player /players/{id}/matches (lobby practice +
|
||||
tournament), enriches with /matches/{id} for final items + skill builds.
|
||||
Default source: web/data/pro_player_watchlist.json (hand-curated star IDs).
|
||||
Falls back to /proPlayers auto-pick only with --all-pros.
|
||||
|
||||
Output: data/pro_matches.json (Climperor web only; not used by recommend).
|
||||
Per player: /players/{id}/matches (lobby practice + tournament), then
|
||||
/matches/{id} for final items + skill builds.
|
||||
|
||||
Output: web/data/pro_matches.json (Climperor web only; not used by recommend).
|
||||
|
||||
Usage:
|
||||
python fetch_pro_matches.py --limit-pros 20 --limit 8
|
||||
python fetch_pro_matches.py --players 1296625,117421467
|
||||
python fetch_pro_matches.py --with-team --active-days 45
|
||||
python web/fetch_pro_matches.py
|
||||
python web/fetch_pro_matches.py --limit 12
|
||||
python web/fetch_pro_matches.py --players 898754153,Ame
|
||||
python web/fetch_pro_matches.py --all-pros --limit-pros 20 --with-team
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,22 +43,84 @@ from fetch_pro_builds import fetch_pro_index
|
||||
|
||||
OPENDOTA = "https://api.opendota.com/api"
|
||||
OUT = DATA / "pro_matches.json"
|
||||
WATCHLIST = DATA / "pro_player_watchlist.json"
|
||||
DEFAULT_LIMIT = 8
|
||||
DEFAULT_LIMIT_PROS = 40
|
||||
# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased).
|
||||
LOBBY_LEAGUE = (1, 2)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
try:
|
||||
print(msg, flush=True)
|
||||
except UnicodeEncodeError:
|
||||
print(msg.encode("ascii", "backslashreplace").decode("ascii"), flush=True)
|
||||
|
||||
|
||||
def load_watchlist(path: Path) -> list[dict]:
|
||||
"""Load hand-curated star players from pro_player_watchlist.json."""
|
||||
if not path.is_file():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
raise SystemExit(f"watchlist read failed: {path}: {e}") from e
|
||||
players = raw.get("players") if isinstance(raw, dict) else None
|
||||
if not isinstance(players, list):
|
||||
raise SystemExit(f"watchlist missing players[]: {path}")
|
||||
out: list[dict] = []
|
||||
seen: set[int] = set()
|
||||
for row in players:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
aid = int(row.get("account_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if aid <= 0 or aid in seen:
|
||||
continue
|
||||
seen.add(aid)
|
||||
name = row.get("name")
|
||||
if isinstance(name, str):
|
||||
name = name.strip() or None
|
||||
else:
|
||||
name = None
|
||||
team_tag = row.get("team_tag")
|
||||
if isinstance(team_tag, str):
|
||||
team_tag = team_tag.strip() or None
|
||||
else:
|
||||
team_tag = None
|
||||
team_name = row.get("team_name")
|
||||
if isinstance(team_name, str):
|
||||
team_name = team_name.strip() or None
|
||||
else:
|
||||
team_name = None
|
||||
region = row.get("region")
|
||||
if isinstance(region, str):
|
||||
region = region.strip().lower() or None
|
||||
else:
|
||||
region = None
|
||||
out.append(
|
||||
{
|
||||
"account_id": aid,
|
||||
"name": name,
|
||||
"team_tag": team_tag,
|
||||
"team_name": team_name,
|
||||
"region": region,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
|
||||
"""Comma-separated account ids or registered pro names."""
|
||||
if not raw.strip():
|
||||
return []
|
||||
name_to_id: dict[str, int] = {}
|
||||
for aid, prof in pro_index.items():
|
||||
for key in ("name",):
|
||||
val = prof.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
name_to_id[val.strip().lower()] = aid
|
||||
val = prof.get("name")
|
||||
if isinstance(val, str) and val.strip():
|
||||
name_to_id[val.strip().lower()] = aid
|
||||
out: list[int] = []
|
||||
for part in raw.split(","):
|
||||
token = part.strip()
|
||||
@@ -67,7 +133,36 @@ def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]:
|
||||
if aid:
|
||||
out.append(aid)
|
||||
else:
|
||||
print(f" warn: unknown pro {token!r}", flush=True)
|
||||
_log(f" warn: unknown pro {token!r}")
|
||||
return out
|
||||
|
||||
|
||||
def merge_prof(aid: int, base: dict | None, override: dict | None = None) -> dict:
|
||||
"""Prefer OpenDota live fields, keep watchlist name/team as fallback."""
|
||||
out: dict = {
|
||||
"account_id": aid,
|
||||
"name": None,
|
||||
"team_tag": None,
|
||||
"team_name": None,
|
||||
"country_code": None,
|
||||
"last_match_time": None,
|
||||
}
|
||||
if isinstance(base, dict):
|
||||
for key in out:
|
||||
if key == "account_id":
|
||||
continue
|
||||
val = base.get(key)
|
||||
if val is not None and val != "":
|
||||
out[key] = val
|
||||
if isinstance(override, dict):
|
||||
for key in ("name", "team_tag", "team_name"):
|
||||
if out.get(key):
|
||||
continue
|
||||
val = override.get(key)
|
||||
if isinstance(val, str) and val.strip():
|
||||
out[key] = val.strip()
|
||||
elif val:
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
@@ -78,13 +173,19 @@ def filter_pros(
|
||||
active_days: int | None,
|
||||
limit_pros: int,
|
||||
player_ids: list[int],
|
||||
watchlist: list[dict] | None = None,
|
||||
) -> list[tuple[int, dict]]:
|
||||
if player_ids:
|
||||
rows: list[tuple[int, dict]] = []
|
||||
for aid in player_ids:
|
||||
prof = pro_index.get(aid)
|
||||
if prof:
|
||||
rows.append((aid, prof))
|
||||
rows.append((aid, merge_prof(aid, pro_index.get(aid))))
|
||||
return rows
|
||||
|
||||
if watchlist:
|
||||
rows = []
|
||||
for entry in watchlist:
|
||||
aid = int(entry["account_id"])
|
||||
rows.append((aid, merge_prof(aid, pro_index.get(aid), entry)))
|
||||
return rows
|
||||
|
||||
cutoff = None
|
||||
@@ -110,7 +211,7 @@ def filter_pros(
|
||||
|
||||
candidates.sort(key=lambda t: (-t[2], str(t[1].get("name") or ""), t[0]))
|
||||
picked = candidates[: max(1, limit_pros)]
|
||||
return [(aid, prof) for aid, prof, _ in picked]
|
||||
return [(aid, merge_prof(aid, prof)) for aid, prof, _ in picked]
|
||||
|
||||
|
||||
def player_match_metas(
|
||||
@@ -266,6 +367,7 @@ def write_out(
|
||||
limit: int,
|
||||
limit_pros: int,
|
||||
lobby_types: tuple[int, ...],
|
||||
player_source: str,
|
||||
) -> None:
|
||||
used = collect_item_ids(by_hero)
|
||||
items_out = {
|
||||
@@ -287,6 +389,7 @@ def write_out(
|
||||
"source": "opendota",
|
||||
"attribution": "https://www.opendota.com",
|
||||
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
||||
"player_source": player_source,
|
||||
"limit_per_pro": limit,
|
||||
"limit_pros": limit_pros,
|
||||
"lobby_types": list(lobby_types),
|
||||
@@ -294,7 +397,8 @@ def write_out(
|
||||
"match_count": match_count,
|
||||
"hero_count": len(by_hero),
|
||||
"note_zh": (
|
||||
"OpenDota 注册职业选手近期联赛/锦标赛对局;"
|
||||
"OpenDota 明星/职业选手近期联赛/锦标赛对局;"
|
||||
"默认名单见 web/data/pro_player_watchlist.json;"
|
||||
"lobby_type 1=训练/practice、2=tournament;"
|
||||
"含终局出装、加点与联赛名(若有)。"
|
||||
),
|
||||
@@ -310,16 +414,15 @@ def write_out(
|
||||
)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
try:
|
||||
print(msg, flush=True)
|
||||
except UnicodeEncodeError:
|
||||
print(msg.encode("ascii", "backslashreplace").decode("ascii"), flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument(
|
||||
"--watchlist",
|
||||
type=Path,
|
||||
default=WATCHLIST,
|
||||
help=f"Star player list JSON (default: {WATCHLIST})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
@@ -330,23 +433,28 @@ def main() -> None:
|
||||
"--limit-pros",
|
||||
type=int,
|
||||
default=DEFAULT_LIMIT_PROS,
|
||||
help=f"Max pros when --players omitted (default: {DEFAULT_LIMIT_PROS})",
|
||||
help=f"Max pros when --all-pros (default: {DEFAULT_LIMIT_PROS})",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--players",
|
||||
default="",
|
||||
help="Comma-separated account_id or registered pro name (overrides --limit-pros)",
|
||||
help="Comma-separated account_id or registered pro name (overrides watchlist)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--all-pros",
|
||||
action="store_true",
|
||||
help="Ignore watchlist; auto-pick from /proPlayers (see --limit-pros/--with-team)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--with-team",
|
||||
action="store_true",
|
||||
help="Only pros with a team_tag/team_name when picking from /proPlayers",
|
||||
help="With --all-pros: only pros with a team_tag/team_name",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--active-days",
|
||||
type=int,
|
||||
default=60,
|
||||
help="Skip pros with no last_match_time within N days (0=disable; default: 60)",
|
||||
help="With --all-pros: skip pros idle > N days (0=disable; default: 60)",
|
||||
)
|
||||
ap.add_argument("--delay", type=float, default=0.35)
|
||||
ap.add_argument(
|
||||
@@ -368,12 +476,30 @@ def main() -> None:
|
||||
_log(f" {len(pro_index)} registered pros")
|
||||
|
||||
player_ids = parse_pro_filter(args.players, pro_index)
|
||||
watchlist: list[dict] | None = None
|
||||
player_source = "cli"
|
||||
|
||||
if player_ids:
|
||||
player_source = "cli"
|
||||
elif not args.all_pros:
|
||||
watchlist = load_watchlist(args.watchlist)
|
||||
if not watchlist:
|
||||
raise SystemExit(
|
||||
f"Empty/missing watchlist {args.watchlist}. "
|
||||
"Add players or pass --players / --all-pros."
|
||||
)
|
||||
player_source = f"watchlist:{args.watchlist.name}"
|
||||
_log(f" watchlist {len(watchlist)} players ← {args.watchlist}")
|
||||
else:
|
||||
player_source = "proPlayers"
|
||||
|
||||
picked = filter_pros(
|
||||
pro_index,
|
||||
with_team=args.with_team,
|
||||
active_days=active_days,
|
||||
limit_pros=limit_pros,
|
||||
player_ids=player_ids,
|
||||
watchlist=watchlist,
|
||||
)
|
||||
if not picked:
|
||||
raise SystemExit("No pros matched filters")
|
||||
@@ -429,6 +555,7 @@ def main() -> None:
|
||||
limit=limit,
|
||||
limit_pros=len(picked),
|
||||
lobby_types=lobby_types,
|
||||
player_source=player_source,
|
||||
)
|
||||
_log(
|
||||
f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)} → {args.out}"
|
||||
|
||||
+576
-79
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||
var SITE_VERSION = "0.5.61";
|
||||
var SITE_VERSION = "0.5.71";
|
||||
var ABILITY_VIDEO_BASE = "";
|
||||
var STATIC_ASSET_BASE = "";
|
||||
|
||||
+17
-4
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>DOTA2 上分帝</title>
|
||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.61" />
|
||||
<link rel="stylesheet" href="/style.css?v=0.5.71" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
@@ -27,6 +27,7 @@
|
||||
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
||||
<button type="button" class="main-tab" data-page="rankings">排行</button>
|
||||
<button type="button" class="main-tab" data-page="streamers">主播</button>
|
||||
<button type="button" class="main-tab" data-page="matches">比赛</button>
|
||||
<button type="button" class="main-tab" data-page="trends">走势</button>
|
||||
<button type="button" class="main-tab" data-page="mechanics">机制</button>
|
||||
<button type="button" class="main-tab" data-page="items">物品</button>
|
||||
@@ -67,6 +68,18 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="matches-view" class="board rankings-board matches-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap matches-center-wrap">
|
||||
<div class="rankings-body matches-body" id="matches-body"></div>
|
||||
<aside class="rankings-aside" aria-label="选手筛选">
|
||||
<div class="matches-players" id="matches-players" role="tablist" aria-label="选择选手"></div>
|
||||
<p class="rankings-sub" id="matches-sub"></p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<main id="streamers-view" class="board rankings-board streamers-board hidden">
|
||||
<div class="rankings-cluster">
|
||||
<div class="rankings-center-wrap streamers-center-wrap">
|
||||
@@ -136,8 +149,8 @@
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/config.js?v=0.5.61"></script>
|
||||
<script src="/router.js?v=0.5.61"></script>
|
||||
<script src="/app.js?v=0.5.61"></script>
|
||||
<script src="/config.js?v=0.5.71"></script>
|
||||
<script src="/router.js?v=0.5.71"></script>
|
||||
<script src="/app.js?v=0.5.71"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+13
-2
@@ -4,11 +4,13 @@
|
||||
* Hash-based router for the Climperor web site (web/relations).
|
||||
*
|
||||
* Synchronizes the browser URL with app state across these dimensions:
|
||||
* - page: heroes | rankings | streamers | trends | mechanics | items | patches (top-level tab)
|
||||
* - page: heroes | rankings | matches | streamers | trends | mechanics | items | patches (top-level tab)
|
||||
* - hero: selected hero key + detail sub-tab
|
||||
* (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats → trends)
|
||||
* - rankings: Immortal leaderboard region
|
||||
* #/rankings[/region] (legacy #/rankings/meta[/bracket] → players / china)
|
||||
* - matches: star-player recent matches (pro_matches)
|
||||
* #/matches[/account_id]
|
||||
* - streamers: curated Douyin streamer directory
|
||||
* #/streamers
|
||||
* - trends: medal bracket for the 8-week win/pick board
|
||||
@@ -30,7 +32,7 @@
|
||||
*/
|
||||
|
||||
const ROUTE_DEFAULT = "#/heroes";
|
||||
const VALID_PAGES = ["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"];
|
||||
const VALID_PAGES = ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"];
|
||||
const VALID_DETAIL_TABS = [
|
||||
"skills",
|
||||
"core",
|
||||
@@ -92,6 +94,7 @@ function parseHash(hash) {
|
||||
itemKey: null,
|
||||
patchVersion: null,
|
||||
rankingRegion: null,
|
||||
matchesPlayerId: null,
|
||||
trendsBracket: null,
|
||||
trendsSort: null,
|
||||
mechanicEffect: null,
|
||||
@@ -118,6 +121,10 @@ function parseHash(hash) {
|
||||
if (segs[1] && segs[1] !== "meta") {
|
||||
out.rankingRegion = safeDecode(segs[1]);
|
||||
}
|
||||
} else if (page === "matches") {
|
||||
if (segs[1] && /^\d+$/.test(segs[1])) {
|
||||
out.matchesPlayerId = segs[1];
|
||||
}
|
||||
} else if (page === "trends") {
|
||||
if (segs[1]) out.trendsBracket = safeDecode(segs[1]);
|
||||
} else if (page === "mechanics") {
|
||||
@@ -175,6 +182,10 @@ function serializeHash(state) {
|
||||
if (region && region !== DEFAULT_RANKING_REGION) {
|
||||
hash += "/" + encodeURIComponent(region);
|
||||
}
|
||||
} else if (state.page === "matches") {
|
||||
if (state.matchesPlayerId) {
|
||||
hash += "/" + encodeURIComponent(String(state.matchesPlayerId));
|
||||
}
|
||||
} else if (state.page === "trends") {
|
||||
// Omit bracket when it is the default (legend) — bare #/trends means legend.
|
||||
const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET;
|
||||
|
||||
+145
-4
@@ -700,7 +700,7 @@ body {
|
||||
}
|
||||
.matchup-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1fr auto auto;
|
||||
grid-template-columns: 64px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
@@ -726,24 +726,73 @@ body {
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.matchup-mid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.matchup-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
.matchup-meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
.matchup-score {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 14px;
|
||||
color: #7eb6ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.matchup-score.is-agree {
|
||||
color: #7ecf9a;
|
||||
}
|
||||
.matchup-score.is-conflict {
|
||||
color: #e0a06a;
|
||||
}
|
||||
.matchup-games {
|
||||
font-size: 12px;
|
||||
min-width: 2.75rem;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.matchup-scope-hint,
|
||||
.matchup-freshness,
|
||||
.matchup-notes {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.matchup-notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
.matchup-note-line,
|
||||
.matchup-note-time {
|
||||
margin: 0;
|
||||
}
|
||||
.matchup-note-line {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.matchup-note-time {
|
||||
margin-top: 2px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.matchup-note-line.is-stale,
|
||||
.matchup-freshness.is-stale {
|
||||
color: #e0a06a;
|
||||
}
|
||||
.meta-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1716,6 +1765,36 @@ body {
|
||||
.match-card.is-loss .match-result {
|
||||
color: rgba(230, 130, 130, 0.95);
|
||||
}
|
||||
.match-hero {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
max-width: 9.5em;
|
||||
}
|
||||
.match-hero:hover .match-hero-name {
|
||||
color: rgba(255, 220, 160, 0.95);
|
||||
}
|
||||
.match-hero-icon {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
flex: 0 0 auto;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.match-hero-name {
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.match-kda {
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
@@ -2522,6 +2601,24 @@ body:has(#items-view:not(.hidden)) {
|
||||
outline: 2px solid var(--sel);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.matches-board .matches-body {
|
||||
min-height: 0;
|
||||
}
|
||||
.matches-center-wrap {
|
||||
align-items: stretch;
|
||||
}
|
||||
.matches-players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: min(70vh, 560px);
|
||||
overflow: auto;
|
||||
scrollbar-width: thin;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.matches-board .match-list {
|
||||
gap: 12px;
|
||||
}
|
||||
.rankings-empty {
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
@@ -3344,7 +3441,7 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -3391,14 +3488,58 @@ a.streamer-avatar-wrap:hover .streamer-avatar {
|
||||
padding-top: 2px;
|
||||
}
|
||||
.streamer-video-frame {
|
||||
/* Default 9:16; JS may set aspect-ratio from video metadata */
|
||||
/* Real ratio set by JS after metadata; pending uses compact skeleton. */
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #0a0a0a;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.streamer-video-frame.is-pending {
|
||||
aspect-ratio: 16 / 9;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
position: relative;
|
||||
background: linear-gradient(90deg, #121212 0%, #1a1a1a 45%, #121212 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: streamer-video-skeleton 1.15s ease-in-out infinite;
|
||||
}
|
||||
.streamer-video-frame.is-pending.has-poster {
|
||||
background-color: #0a0a0a;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
animation: none;
|
||||
}
|
||||
.streamer-video-frame.is-pending::after {
|
||||
content: "视频加载中";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
pointer-events: none;
|
||||
}
|
||||
.streamer-video-frame.is-pending.has-poster::after {
|
||||
content: "";
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.35) 0%,
|
||||
rgba(0, 0, 0, 0.08) 45%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
.streamer-video-frame.is-pending .streamer-video {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes streamer-video-skeleton {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
.streamer-video-frame.is-landscape {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user