ghassan 04d63cccdb VS intro screen (arena design), card VS mini + live scorebar, full country names, sports-match edit fix
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>
2026-08-10 18:34:31 +03:00

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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