Match player + preview
- Rebuild VS intro as a 1920×1080 scaled canvas (arena design from drafts/):
angled RED/BLUE panels with clip-paths, slam-in VS with pulse+shine,
event/stage/weight header, Match/Court/Referee chips, per-fighter
Record/Rank/Stats chip row. Every placeholder from the artifact is
always in the DOM; empty ones are hidden by `.vs-nullable:empty`
and `.vs-nullable-hide` so the layout adapts without losing the
design's HTML shape. Country codes always render as full country
names (via new App\Data\Countries::name()).
- Real-time 6s countdown Skip button; video pinned at t=0 while
the intro runs, released to play at zero or on Skip.
- Standalone preview page at /videos/{video}/vs-preview.
Video cards (home + everywhere)
- Match cards get a `vs-mini` overlay of the same arena design
(fixed canvas, per-card ResizeObserver, animations replay every
time the mouse leaves the thumb). On hover the mini explodes
outward (panels fly out, VS scales+blurs+fades) and the hover
video plays over the cleared thumb.
- Match cards also render a `scorebar-mini` overlay (verbatim of
the full-player msb-scale scorebar + msb-feed) that shows
during hover playback. Live-scoring is wired per-card via a
data-sbm-state payload of rounds+points; RED/BLUE score, round
label, per-round clock, and the top-right Live Scoring ticker
(4 entries, sport-aware Yuko/Waza-ari/Ippon or Punch/Body
kick/… labels) all update from the mini video's timeupdate.
Bug fixes
- Hover video wasn't playing sound: dropped the `muted` attribute
I'd added while debugging match previews.
- White flash strobing on the left of match cards: removed the
`.vs-mini-flash` element (intro-only flourish that fired on
every mouseleave replay).
- Sports-match modal was showing "Please complete the required
fields" when saving basic info for match videos that had no
SportsMatch row yet: the submit gate only checked matchId, not
the attached videoId; now it correctly skips uploadVideoFirst()
in both edit and attach-existing-video modes.
Country names project-wide
- New helper App\Data\Countries::name($iso2) → full country name.
- Swapped `?? country` fallbacks to `?? Countries::name(country)`
in admin dashboard, video-analytics, video-insights payloads
(VideoController + SuperAdminController), and the VS partials.
Now user-visible country labels always show "Bahrain" instead
of "BH", including chart tooltips and modal titles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
378 lines
20 KiB
PHP
378 lines
20 KiB
PHP
{{-- ══════════════════════════════════════════════════════════════════════
|
|
SCOREBAR MINI — the same match scorebar shown on the full player,
|
|
rendered inside a video-card thumbnail during hover playback.
|
|
|
|
Uses the design's fixed 1150 px canvas (identical to the main player's
|
|
msb-scale block). Every card scales its own canvas via ResizeObserver
|
|
so the scorebar keeps its pixel-perfect proportions at any card size.
|
|
|
|
Static-only: fighter names, flags, clubs, corner labels, "Round 1"
|
|
and 0-0 / 0:00. No live-scoring script — hover previews cover the
|
|
first few seconds of the match where those values are always the
|
|
baseline anyway.
|
|
════════════════════════════════════════════════════════════════════════ --}}
|
|
@php
|
|
$sbm_hd = $video->sportsMatch?->headerData();
|
|
$sbm_red = $sbm_hd['red'] ?? null;
|
|
$sbm_blue = $sbm_hd['blue'] ?? null;
|
|
$sbm_show = $sbm_hd && (($sbm_red['name'] ?? null) || ($sbm_blue['name'] ?? null));
|
|
@endphp
|
|
|
|
@if ($sbm_show)
|
|
@php
|
|
$sbm_redName = $sbm_red['name'] ?? 'RED';
|
|
$sbm_blueName = $sbm_blue['name'] ?? 'BLUE';
|
|
$sbm_redClub = $sbm_red['club'] ?? '';
|
|
$sbm_blueClub = $sbm_blue['club'] ?? '';
|
|
$sbm_redFlag = strtolower(trim((string) ($sbm_red['flag'] ?? '')));
|
|
$sbm_blueFlag = strtolower(trim((string) ($sbm_blue['flag'] ?? '')));
|
|
$sbm_redLogo = !empty($sbm_red['club_logo']) ? route('media.thumbnail', $sbm_red['club_logo']) : null;
|
|
$sbm_blueLogo = !empty($sbm_blue['club_logo']) ? route('media.thumbnail', $sbm_blue['club_logo']) : null;
|
|
|
|
// Live-scoring payload — same shape as the full-player scoreboard uses.
|
|
// Rounds drive the round label + per-round clock. Points give us the
|
|
// score at any t (last point ≤ t defines the score).
|
|
$sbm_rounds = $video->matchRounds()
|
|
->orderBy('round_number')
|
|
->get(['round_number', 'name', 'start_time_seconds'])
|
|
->map(fn ($r) => [
|
|
'n' => (int) $r->round_number,
|
|
'name' => (string) ($r->name ?? ''),
|
|
'start' => (float) ($r->start_time_seconds ?? 0),
|
|
])->values()->all();
|
|
if (empty($sbm_rounds)) $sbm_rounds = [['n' => 1, 'name' => '', 'start' => 0]];
|
|
|
|
$sbm_points = $video->matchPoints()
|
|
->orderBy('timestamp_seconds')
|
|
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
|
|
->flatMap(function ($p) {
|
|
// Same rule the full-player uses: a "both" event emits two feed rows
|
|
// — one per corner — so the ticker shows both fighters scoring.
|
|
$side = ($p->competitor === 'both') ? 'red' : $p->competitor;
|
|
$rows = [[
|
|
't' => (float) $p->timestamp_seconds,
|
|
'action' => (string) ($p->action ?? 'Point'),
|
|
'pts' => (int) $p->points,
|
|
'side' => $side,
|
|
'sr' => (int) ($p->score_red ?? 0),
|
|
'sb' => (int) ($p->score_blue ?? 0),
|
|
]];
|
|
if ($p->competitor === 'both') {
|
|
$rows[] = array_merge($rows[0], ['side' => 'blue', 't' => (float) $p->timestamp_seconds + 0.001]);
|
|
}
|
|
return $rows;
|
|
})->values()->all();
|
|
|
|
$sbm_sport = $sbm_hd['sport'] ?? null;
|
|
|
|
$sbm_data = json_encode(['rounds' => $sbm_rounds, 'points' => $sbm_points, 'sport' => $sbm_sport], JSON_HEX_APOS | JSON_HEX_QUOT);
|
|
@endphp
|
|
|
|
<div class="sbm" data-sbm data-sbm-state='{{ $sbm_data }}'>
|
|
{{-- Live scoring ticker — sits in its OWN scaled canvas at the
|
|
top-right of the thumb. Populated by the sbm live-sync JS. --}}
|
|
<div class="sbm-feed-scale">
|
|
<div class="sbm-feed" data-sbm-feed>
|
|
<div class="sbm-feed-hdr"><span class="sbm-feed-dot"></span><span>Live scoring</span></div>
|
|
<div class="sbm-feed-list" data-sbm-feed-list></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sbm-scale">
|
|
{{-- ══ Scorebar markup — same design + inline styles as the
|
|
overlay.blade.php `data-msb-part="scorebar"` block, but stripped
|
|
of the live-sync data-msb hooks. ══ --}}
|
|
<div style="position:absolute;left:0;right:0;bottom:14px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;pointer-events:none">
|
|
|
|
{{-- RED / AKA --}}
|
|
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
|
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
|
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
|
@if($sbm_redLogo) background-image:url('{{ $sbm_redLogo }}'); @endif"></div>
|
|
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
|
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
|
@if ($sbm_redFlag !== '')
|
|
<span class="fi fi-{{ $sbm_redFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
|
@endif
|
|
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redName }}</span>
|
|
</div>
|
|
@if ($sbm_redClub !== '')
|
|
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
|
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redClub }}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
|
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
|
<span data-sbm-red-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- CENTER: round + clock --}}
|
|
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
|
<div data-sbm-round style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
|
<div data-sbm-clock style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
|
|
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
|
<span style="width:20px;height:3px;background:#e8534a"></span>
|
|
<span style="width:20px;height:3px;background:#e8534a"></span>
|
|
<span style="width:20px;height:3px;background:#3a3734"></span>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- BLUE / AO --}}
|
|
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
|
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
|
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
|
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
|
<span data-sbm-blue-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
|
</div>
|
|
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
|
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
|
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_blueName }}</span>
|
|
@if ($sbm_blueFlag !== '')
|
|
<span class="fi fi-{{ $sbm_blueFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
|
@endif
|
|
</div>
|
|
@if ($sbm_blueClub !== '')
|
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;justify-content:flex-end">
|
|
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right">{{ $sbm_blueClub }}</span>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
|
@if($sbm_blueLogo) background-image:url('{{ $sbm_blueLogo }}'); @endif"></div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
@once
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
/* ══ SCOREBAR MINI — same 1150 px canvas as the full player scorebar.
|
|
Sits above the hover-preview <video> (z-index 6, one above the
|
|
video at 5) and only appears while the video is active. ══ */
|
|
.yt-video-thumb .sbm {
|
|
position: absolute; inset: 0;
|
|
z-index: 6;
|
|
font-family: 'Barlow Condensed', sans-serif;
|
|
color: #efe9e0;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity .25s ease .05s;
|
|
overflow: hidden;
|
|
-webkit-font-smoothing: antialiased;
|
|
--sbm-scale: 0.4;
|
|
}
|
|
.sbm * { box-sizing: border-box; }
|
|
|
|
/* Show only while the hover video is playing (playVideo() adds .active) */
|
|
.yt-video-thumb:has(video.active) .sbm { opacity: 1; }
|
|
|
|
.sbm .sbm-scale {
|
|
position: absolute; bottom: 0; left: 0;
|
|
width: 1150px; height: 100px;
|
|
transform-origin: bottom left;
|
|
transform: scale(var(--sbm-scale));
|
|
}
|
|
|
|
/* ── Live scoring feed (top-right) — SAME 1150 px design canvas as the
|
|
full-player msb-feed, so the sizes and positions are pixel-identical.
|
|
Scaled by --sbm-scale (same as the bottom scorebar) so the feed
|
|
shrinks/grows in lockstep with the rest of the overlay. ── */
|
|
.sbm .sbm-feed-scale {
|
|
position: absolute; top: 0; left: 0;
|
|
width: 1150px; height: 100%;
|
|
transform-origin: top left;
|
|
transform: scale(var(--sbm-scale));
|
|
pointer-events: none;
|
|
}
|
|
/* Verbatim copy of .msb-feed / .msb-feed .hdr / .msb-feed .entry from
|
|
videos/partials/match/scoreboard/styles.blade.php — only the class
|
|
names are prefixed .sbm- to avoid colliding with the full player
|
|
when a card is opened as a sub-page. */
|
|
.sbm .sbm-feed {
|
|
position: absolute; top: 22px; right: 24px;
|
|
width: 262px;
|
|
display: flex; flex-direction: column; gap: 7px;
|
|
}
|
|
.sbm .sbm-feed-hdr {
|
|
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
|
|
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
|
|
font-family: 'Barlow Condensed', sans-serif;
|
|
text-shadow: 0 1px 6px rgba(0,0,0,.9);
|
|
}
|
|
.sbm .sbm-feed-dot {
|
|
width: 7px; height: 7px; border-radius: 50%;
|
|
background: #e8534a; animation: sbmPulse 1.6s infinite;
|
|
}
|
|
.sbm .sbm-feed-list { display: flex; flex-direction: column; gap: 7px; }
|
|
.sbm .sbm-feed-entry {
|
|
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
|
padding: 7px 10px;
|
|
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
|
|
border-right: 3px solid transparent;
|
|
animation: sbmRiseIn .45s ease both;
|
|
}
|
|
.sbm .sbm-feed-entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; font-family: 'Barlow Condensed', sans-serif; }
|
|
.sbm .sbm-feed-entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; font-family: 'Barlow Condensed', sans-serif; }
|
|
.sbm .sbm-feed-entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
|
|
|
|
/* Hide feed on very small thumbs (matches the msb-small breakpoint scaled
|
|
down for cards — below ~340 px it becomes illegible). */
|
|
.sbm.sbm-small .sbm-feed { display: none; }
|
|
|
|
@keyframes sbmPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
|
@keyframes sbmRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
|
</style>
|
|
|
|
<script>
|
|
(function () {
|
|
if (window._sbmBound) return;
|
|
window._sbmBound = true;
|
|
|
|
// ── Fit the fixed 1150 px scorebar canvas to the parent thumb ──
|
|
function fit(el) {
|
|
const host = el.parentElement;
|
|
if (!host) return;
|
|
const w = host.clientWidth;
|
|
if (!w) return;
|
|
el.style.setProperty('--sbm-scale', (w / 1150));
|
|
// Below ~340 px thumb width the ticker would shrink under legibility
|
|
// (matches the full player's `msb-small` breakpoint at 700 px, halved
|
|
// for card contexts). Hide the feed on those tiny cards.
|
|
el.classList.toggle('sbm-small', w < 340);
|
|
}
|
|
|
|
// ── Live scoring sync ─────────────────────────────────────────
|
|
// Each .sbm carries its match state as a data attribute. Bind the
|
|
// sibling <video>'s timeupdate to updates on THIS card's scorebar
|
|
// only — no globals, no ID collisions with other cards on the page.
|
|
function fmtClock(sec) {
|
|
sec = Math.max(0, Math.floor(sec || 0));
|
|
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
|
}
|
|
function currentRound(rounds, t) {
|
|
if (!rounds || !rounds.length) return { n: 1, name: '', start: 0 };
|
|
let cur = rounds[0];
|
|
for (const r of rounds) { if (t >= (r.start || 0)) cur = r; }
|
|
return cur;
|
|
}
|
|
function lastPoint(points, t) {
|
|
if (!points || !points.length) return null;
|
|
let last = null;
|
|
for (const p of points) { if (p.t <= t + 0.001) last = p; else break; }
|
|
return last;
|
|
}
|
|
// Same label rules as the full player: karate → Yuko/Waza-ari/Ippon,
|
|
// taekwondo → Punch/Body kick/Head kick/Turning body, else action text.
|
|
function pointLabel(p, sport) {
|
|
sport = (sport || '').toLowerCase();
|
|
if (sport.startsWith('taekwondo')) {
|
|
if (p.pts >= 4) return 'Turning body';
|
|
if (p.pts === 3) return 'Head kick';
|
|
if (p.pts === 2) return 'Body kick';
|
|
if (p.pts === 1) return 'Punch';
|
|
}
|
|
if (p.pts === 3) return 'Ippon';
|
|
if (p.pts === 2) return 'Waza-ari';
|
|
if (p.pts === 1) return 'Yuko';
|
|
return p.action || 'Point';
|
|
}
|
|
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
const feedColor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
|
|
|
|
function bindLive(sbm) {
|
|
let state = null;
|
|
try { state = JSON.parse(sbm.getAttribute('data-sbm-state') || 'null'); }
|
|
catch (e) { return; }
|
|
if (!state) return;
|
|
|
|
const thumb = sbm.parentElement;
|
|
const video = thumb && thumb.querySelector(':scope > video');
|
|
if (!video) return;
|
|
|
|
const redEl = sbm.querySelector('[data-sbm-red-score]');
|
|
const blueEl = sbm.querySelector('[data-sbm-blue-score]');
|
|
const roundEl = sbm.querySelector('[data-sbm-round]');
|
|
const clockEl = sbm.querySelector('[data-sbm-clock]');
|
|
const feedEl = sbm.querySelector('[data-sbm-feed-list]');
|
|
let lastR = null, lastB = null, lastRoundTxt = null, lastClk = null, lastFeedSig = null;
|
|
|
|
const tick = () => {
|
|
const t = video.currentTime || 0;
|
|
const p = lastPoint(state.points, t);
|
|
const r = currentRound(state.rounds, t);
|
|
|
|
const sr = p ? p.sr : 0, sb = p ? p.sb : 0;
|
|
if (sr !== lastR) { if (redEl) redEl.textContent = sr; lastR = sr; }
|
|
if (sb !== lastB) { if (blueEl) blueEl.textContent = sb; lastB = sb; }
|
|
|
|
const roundTxt = r.name || ('Round ' + r.n);
|
|
if (roundTxt !== lastRoundTxt) { if (roundEl) roundEl.textContent = roundTxt; lastRoundTxt = roundTxt; }
|
|
|
|
const clk = fmtClock(Math.max(0, t - (r.start || 0)));
|
|
if (clk !== lastClk) { if (clockEl) clockEl.textContent = clk; lastClk = clk; }
|
|
|
|
// Live scoring ticker — newest 3 point events, newest on top.
|
|
if (feedEl) {
|
|
const seen = (state.points || []).filter(pt => pt.t <= t + 0.001);
|
|
const last4 = seen.slice(-4).reverse();
|
|
const sig = last4.map(pt => pt.t + ':' + pt.side + ':' + pt.pts).join(',');
|
|
if (sig !== lastFeedSig) {
|
|
feedEl.innerHTML = last4.map(pt => {
|
|
const col = feedColor(pt.side);
|
|
return '<div class="sbm-feed-entry" style="border-color:' + col + '">'
|
|
+ '<span class="ts">' + fmtClock(pt.t) + '</span>'
|
|
+ '<span class="name">' + esc(pointLabel(pt, state.sport)) + '</span>'
|
|
+ '<span class="pts" style="color:' + col + '">+' + pt.pts + '</span>'
|
|
+ '</div>';
|
|
}).join('');
|
|
lastFeedSig = sig;
|
|
}
|
|
}
|
|
};
|
|
video.addEventListener('timeupdate', tick);
|
|
video.addEventListener('seeked', tick);
|
|
video.addEventListener('loadedmetadata', tick);
|
|
// Reset to 0-0 / 0:00 when the video stops (matches VS mini restart).
|
|
video.addEventListener('pause', tick);
|
|
video.addEventListener('emptied', tick);
|
|
tick();
|
|
}
|
|
|
|
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
|
for (const e of entries) {
|
|
const el = e.target.querySelector(':scope > .sbm');
|
|
if (el) fit(el);
|
|
}
|
|
}) : null;
|
|
|
|
function register(el) {
|
|
fit(el);
|
|
if (ro) ro.observe(el.parentElement);
|
|
if (!el._sbmBound) { el._sbmBound = true; bindLive(el); }
|
|
}
|
|
|
|
document.querySelectorAll('.sbm').forEach(register);
|
|
|
|
new MutationObserver(muts => {
|
|
for (const m of muts) {
|
|
for (const n of m.addedNodes) {
|
|
if (!(n instanceof Element)) continue;
|
|
if (n.matches?.('.sbm')) register(n);
|
|
n.querySelectorAll?.('.sbm').forEach(register);
|
|
}
|
|
}
|
|
}).observe(document.body, { childList: true, subtree: true });
|
|
})();
|
|
</script>
|
|
@endonce
|