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>
This commit is contained in:
ghassan 2026-08-10 18:34:31 +03:00
parent 5b6bd86d70
commit 04d63cccdb
16 changed files with 2351 additions and 411 deletions

View File

@ -8,6 +8,18 @@ class Countries
* All countries keyed by ISO2 code. * All countries keyed by ISO2 code.
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency * Fields: name, iso2, iso3, flag, dial_code, timezone, currency
*/ */
/**
* Resolve any ISO2 code (case-insensitive) to its full country name.
* Falls back to the uppercased code, then to null for empty input.
* Use this instead of printing raw two-letter codes to end users.
*/
public static function name(?string $iso2): ?string
{
$code = strtoupper(trim((string) $iso2));
if ($code === '') return null;
return self::all()[$code]['name'] ?? $code;
}
public static function all(): array public static function all(): array
{ {
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library) // Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)

View File

@ -506,7 +506,7 @@ class SuperAdminController extends Controller
foreach ($uniqueViewers as $viewer) { foreach ($uniqueViewers as $viewer) {
if (!$viewer->country) continue; if (!$viewer->country) continue;
if (!isset($countryMap[$viewer->country])) { if (!isset($countryMap[$viewer->country])) {
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0]; $countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name ?: \App\Data\Countries::name($viewer->country), 'total' => 0];
} }
$countryMap[$viewer->country]['total']++; $countryMap[$viewer->country]['total']++;
} }

View File

@ -3383,7 +3383,7 @@ class VideoController extends Controller
$totalGeo = $rawCountries->sum('cnt'); $totalGeo = $rawCountries->sum('cnt');
$countries = $rawCountries->map(fn ($c) => [ $countries = $rawCountries->map(fn ($c) => [
'code' => $c->country, 'code' => $c->country,
'name' => $c->country_name, 'name' => $c->country_name ?: \App\Data\Countries::name($c->country),
'count' => (int) $c->cnt, 'count' => (int) $c->cnt,
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0, 'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
])->values(); ])->values();
@ -3722,7 +3722,7 @@ class VideoController extends Controller
: asset('images/default-avatar.svg')) : asset('images/default-avatar.svg'))
: null, : null,
'country' => $topCountry ? $topCountry->country : null, 'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? $topCountry->country_name : null, 'country_name' => $topCountry ? ($topCountry->country_name ?: \App\Data\Countries::name($topCountry->country)) : null,
'reach' => $accesses, 'reach' => $accesses,
'created_at' => $s->created_at, 'created_at' => $s->created_at,
]; ];
@ -3868,7 +3868,7 @@ class VideoController extends Controller
return response()->json([ return response()->json([
'country' => $country, 'country' => $country,
'country_name' => $countryName ?? $country, 'country_name' => $countryName ?: (\App\Data\Countries::name($country) ?? $country),
'total_views' => $totalViews, 'total_views' => $totalViews,
'registered_users' => $registeredUsers, 'registered_users' => $registeredUsers,
'guest_count' => $guestCount, 'guest_count' => $guestCount,
@ -3943,7 +3943,7 @@ class VideoController extends Controller
->orderByDesc('cnt') ->orderByDesc('cnt')
->limit(5) ->limit(5)
->get() ->get()
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]); ->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name ?: \App\Data\Countries::name($c->country), 'count' => (int) $c->cnt]);
return response()->json([ return response()->json([
'date' => $day->format('M d, Y'), 'date' => $day->format('M d, Y'),
@ -3971,7 +3971,7 @@ class VideoController extends Controller
->map(fn ($r) => [ ->map(fn ($r) => [
'type' => $r->type, 'type' => $r->type,
'country' => $r->country, 'country' => $r->country,
'country_name' => $r->country_name, 'country_name' => $r->country_name ?: \App\Data\Countries::name($r->country),
'at' => $r->downloaded_at, 'at' => $r->downloaded_at,
]); ]);
@ -4101,7 +4101,7 @@ class VideoController extends Controller
foreach ($accesses as $a) { foreach ($accesses as $a) {
$code = $a->country ?: 'XX'; $code = $a->country ?: 'XX';
if (! isset($countries[$code])) { if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0]; $countries[$code] = ['code' => $code, 'name' => $a->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
} }
$countries[$code]['count']++; $countries[$code]['count']++;
@ -4199,7 +4199,7 @@ class VideoController extends Controller
foreach ($rows as $r) { foreach ($rows as $r) {
$code = $r->country ?: 'XX'; $code = $r->country ?: 'XX';
if (! isset($countries[$code])) { if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0]; $countries[$code] = ['code' => $code, 'name' => $r->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
} }
$countries[$code]['count']++; $countries[$code]['count']++;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -479,10 +479,10 @@
@else @else
@php $maxViews = $viewsByCountry->first()->total; @endphp @php $maxViews = $viewsByCountry->first()->total; @endphp
@foreach($viewsByCountry as $i => $row) @foreach($viewsByCountry as $i => $row)
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')"> <div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
<div class="country-rank">{{ $i + 1 }}</div> <div class="country-rank">{{ $i + 1 }}</div>
<div class="country-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div> <div class="country-flag" title="{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div> <div class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}</div>
<div class="country-bar-wrap"> <div class="country-bar-wrap">
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div> <div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
</div> </div>
@ -742,7 +742,11 @@ new Chart(document.getElementById('typeChart'), {
// ── Country Chart ─────────────────────────────────────────────── // ── Country Chart ───────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty()) @if($viewsByCountry->isNotEmpty())
(function() { (function() {
const countryData = @json($viewsByCountry); const countryData = @json($viewsByCountry->map(fn($r) => [
'country' => $r->country,
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
'total' => $r->total,
])->values());
const labels = countryData.map(r => r.country_name || r.country || ''); const labels = countryData.map(r => r.country_name || r.country || '');
const values = countryData.map(r => r.total); const values = countryData.map(r => r.total);
const maxVal = Math.max(...values); const maxVal = Math.max(...values);
@ -1015,7 +1019,10 @@ if (typeChartInst) {
// Country bar chart click // Country bar chart click
const countryChartInst = Chart.getChart('countryChart'); const countryChartInst = Chart.getChart('countryChart');
if (countryChartInst) { if (countryChartInst) {
const countryData = @json($viewsByCountry ?? collect()); const countryData = @json(($viewsByCountry ?? collect())->map(fn($r) => [
'country' => $r->country,
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
])->values());
countryChartInst.options.onClick = function(evt, elements) { countryChartInst.options.onClick = function(evt, elements) {
if (!elements.length) return; if (!elements.length) return;
const row = countryData[elements[0].index]; const row = countryData[elements[0].index];

View File

@ -294,7 +294,7 @@ function flagEmoji(string $code): string {
<div class="country-row"> <div class="country-row">
<span class="country-rank">{{ $i + 1 }}</span> <span class="country-rank">{{ $i + 1 }}</span>
<span class="country-flag">{!! flagEmoji($row->country) !!}</span> <span class="country-flag">{!! flagEmoji($row->country) !!}</span>
<span class="country-name">{{ $row->country_name ?? $row->country }}</span> <span class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) }}</span>
<div class="country-bar-wrap"> <div class="country-bar-wrap">
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div> <div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
</div> </div>
@ -423,7 +423,7 @@ function flagEmoji(string $code): string {
<td> <td>
@if($view->country) @if($view->country)
{!! flagEmoji($view->country) !!} {!! flagEmoji($view->country) !!}
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span> <span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? \App\Data\Countries::name($view->country) }}</span>
@else @else
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span> <span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
@endif @endif
@ -498,7 +498,7 @@ new Chart(document.getElementById('dailyChart'), {
// ── Country chart ─────────────────────────────────────────────────────────── // ── Country chart ───────────────────────────────────────────────────────────
@if($viewsByCountry->isNotEmpty()) @if($viewsByCountry->isNotEmpty())
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!}; const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? \App\Data\Countries::name($r->country)))->values()) !!};
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!}; const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
const countryMax = Math.max(...countryData); const countryMax = Math.max(...countryData);

View File

@ -0,0 +1,377 @@
{{-- ══════════════════════════════════════════════════════════════════════
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

View File

@ -0,0 +1,536 @@
{{-- ══════════════════════════════════════════════════════════════════════
VS MINI the arena intro card rendered INSIDE a video-card thumbnail.
No countdown / no skip button; identical layout to the full-page VS
intro (fixed 1920×1080 stage, transform-scaled to fit the thumb).
Behavior:
- Sits at z-index 1 (above the still image, BELOW the hover video
which uses z-index 2 + .active { opacity: 1 }).
- On mouseleave from the thumb, animations restart so the intro
plays again the next time the user's mouse is off the card.
════════════════════════════════════════════════════════════════════════ --}}
@php
$vsm_hd = $video->sportsMatch?->headerData();
$vsm_red = $vsm_hd['red'] ?? null;
$vsm_blue = $vsm_hd['blue'] ?? null;
$vsm_show = $vsm_hd && (($vsm_red['name'] ?? null) || ($vsm_blue['name'] ?? null));
@endphp
@if ($vsm_show)
@php
$vsm_flag = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
$vsm_cname = fn (?string $c) => \App\Data\Countries::name($c);
$vsm_img = fn(?string $p) => $p ? route('media.thumbnail', $p) : null;
$vsm_str = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
// Every placeholder from the design is always in the DOM; empty ones
// are hidden by `.vsm-nullable:empty` / `.vsm-nullable-hide` in the
// CSS below. Same treatment as the full-page VS.
$vsm_event = $vsm_str($vsm_hd['championship'] ?? '');
$vsm_stage = $vsm_str($vsm_hd['division'] ?? '') ?: $vsm_str($vsm_hd['format'] ?? '');
$vsm_weight = $vsm_str($vsm_hd['weight_category'] ?? '');
$vsm_matchNo = $vsm_str($vsm_hd['match_number'] ?? '');
$vsm_court = $vsm_str($vsm_hd['court'] ?? '');
$vsm_ref = $vsm_str($vsm_hd['referee']['name'] ?? '');
// Per-fighter chips (Record / Rank / Stats) — fields aren't in
// headerData() today, so fall back to raw participants JSON.
$vsm_parts = $video->sportsMatch?->participants ?? [];
$vsm_redRecord = $vsm_str($vsm_parts['p2_record'] ?? '');
$vsm_redRank = $vsm_str($vsm_parts['p2_rank'] ?? '');
$vsm_redStats = $vsm_str($vsm_parts['p2_stats'] ?? '');
$vsm_blueRecord = $vsm_str($vsm_parts['p1_record'] ?? '');
$vsm_blueRank = $vsm_str($vsm_parts['p1_rank'] ?? '');
$vsm_blueStats = $vsm_str($vsm_parts['p1_stats'] ?? '');
$vsm_redName = $vsm_str($vsm_red['name'] ?? '');
$vsm_blueName = $vsm_str($vsm_blue['name'] ?? '');
$vsm_redClub = $vsm_str($vsm_red['club'] ?? '');
$vsm_blueClub = $vsm_str($vsm_blue['club'] ?? '');
$vsm_redFlag = $vsm_str($vsm_red['flag'] ?? '');
$vsm_blueFlag = $vsm_str($vsm_blue['flag'] ?? '');
$vsm_redLogo = $vsm_img($vsm_red['club_logo'] ?? null);
$vsm_blueLogo = $vsm_img($vsm_blue['club_logo'] ?? null);
@endphp
<div class="vs-mini vs-mini-run" data-vs-mini>
<div class="vs-mini-stage">
{{-- RED panel --}}
<div class="vs-mini-panel vs-mini-panel-red">
<div class="vs-mini-photo"
@if($vsm_img($vsm_red['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_red['headshot']) }}')"@endif></div>
<div class="vs-mini-scrim vs-mini-scrim-red"></div>
<div class="vs-mini-fade"></div>
</div>
{{-- BLUE panel --}}
<div class="vs-mini-panel vs-mini-panel-blue">
<div class="vs-mini-photo vs-mini-photo-rev"
@if($vsm_img($vsm_blue['headshot'] ?? null)) style="background-image:url('{{ $vsm_img($vsm_blue['headshot']) }}')"@endif></div>
<div class="vs-mini-scrim vs-mini-scrim-blue"></div>
<div class="vs-mini-fade"></div>
</div>
<div class="vs-mini-divider"></div>
{{-- RED identity (left) every placeholder always in DOM ── --}}
<div class="vs-mini-info vs-mini-info-red">
<div class="vs-mini-tag vs-mini-tag-red">AKA · RED</div>
<div class="vs-mini-flag-row">
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_redFlag) }} {{ $vsm_redFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
<div class="vs-mini-country vs-mini-country-red vsm-nullable">{{ $vsm_redFlag !== '' ? strtoupper($vsm_cname($vsm_redFlag) ?? $vsm_redFlag) : '' }}</div>
</div>
<div class="vs-mini-name vsm-nullable">{{ $vsm_redName }}</div>
<div class="vs-mini-club-row">
<div class="vs-mini-logo {{ $vsm_redLogo ? '' : 'vsm-nullable-hide' }}"
@if($vsm_redLogo) style="background-image:url('{{ $vsm_redLogo }}')"@endif></div>
<div class="vs-mini-club vsm-nullable">{{ $vsm_redClub }}</div>
</div>
<div class="vs-mini-chips-row">
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redRecord }}</div>
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_redRank }}</div>
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_redStats }}</div>
</div>
</div>
{{-- BLUE identity (right) ── --}}
<div class="vs-mini-info vs-mini-info-blue">
<div class="vs-mini-tag vs-mini-tag-blue">AO · BLUE</div>
<div class="vs-mini-flag-row vs-mini-flag-row-r">
<span class="vs-mini-flag fi fi-{{ $vsm_flag($vsm_blueFlag) }} {{ $vsm_blueFlag === '' ? 'vsm-nullable-hide' : '' }}"></span>
<div class="vs-mini-country vs-mini-country-blue vsm-nullable">{{ $vsm_blueFlag !== '' ? strtoupper($vsm_cname($vsm_blueFlag) ?? $vsm_blueFlag) : '' }}</div>
</div>
<div class="vs-mini-name vsm-nullable">{{ $vsm_blueName }}</div>
<div class="vs-mini-club-row vs-mini-club-row-r">
<div class="vs-mini-logo {{ $vsm_blueLogo ? '' : 'vsm-nullable-hide' }}"
@if($vsm_blueLogo) style="background-image:url('{{ $vsm_blueLogo }}')"@endif></div>
<div class="vs-mini-club vsm-nullable">{{ $vsm_blueClub }}</div>
</div>
<div class="vs-mini-chips-row">
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueRecord }}</div>
<div class="vs-mini-chip-fighter vs-mini-chip-fighter-gold vsm-nullable">{{ $vsm_blueRank }}</div>
<div class="vs-mini-chip-fighter vsm-nullable">{{ $vsm_blueStats }}</div>
</div>
</div>
{{-- Top: event + stage + weight (all in DOM) --}}
<div class="vs-mini-top">
<div class="vs-mini-top-event vsm-nullable">{{ $vsm_event }}</div>
<div class="vs-mini-top-stage-row {{ $vsm_stage === '' ? 'vsm-nullable-hide' : '' }}">
<div class="vs-mini-top-line vs-mini-top-line-l"></div>
<div class="vs-mini-top-stage">{{ $vsm_stage }}</div>
<div class="vs-mini-top-line vs-mini-top-line-r"></div>
</div>
<div class="vs-mini-top-weight vsm-nullable">{{ $vsm_weight }}</div>
</div>
{{-- Center VS --}}
<div class="vs-mini-center">
<div class="vs-mini-word">VS
<div class="vs-mini-shine-wrap"><div class="vs-mini-shine"></div></div>
</div>
</div>
{{-- .vs-mini-flash removed the intro's full-thumb white flash at
t=1.5s was firing on every mouseleave-triggered replay, which
read as strobing on the left edge of the card. --}}
{{-- Bottom chips Match / Court / Referee always in DOM; each hides
via data-vsm-empty and the flanking diamonds collapse via :has(). --}}
<div class="vs-mini-bottom">
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_matchNo === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Match</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_matchNo }}</span>
</div>
<div class="vs-mini-diamond vs-mini-diamond-mc"></div>
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_court === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Court</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_court }}</span>
</div>
<div class="vs-mini-diamond vs-mini-diamond-cr"></div>
<div class="vs-mini-chip vsm-nullable-chip" data-vsm-empty="{{ $vsm_ref === '' ? '1' : '0' }}">
<span class="vs-mini-chip-lbl">Referee</span><span class="vs-mini-chip-val vsm-nullable">{{ $vsm_ref }}</span>
</div>
</div>
</div>
</div>
@endif
@once
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
<style>
/* ══ VS MINI arena intro inside a video-card thumbnail.
Same 1920×1080 canvas as the full-page overlay, transform-scaled
to fit the thumb. Every child in ABSOLUTE PIXELS on that canvas.
z-index 1: below the hover <video> (which we bump to 5 for match
cards), above the still <img>. ══ */
.yt-video-thumb .vs-mini {
position: absolute; inset: 0; z-index: 1;
background: #050507;
font-family: 'Barlow Condensed', sans-serif;
color: #e8e6e0;
overflow: hidden;
pointer-events: none;
--vs-mini-scale: 0.5;
--vs-mini-w: 1920px;
--vs-mini-h: 1080px;
}
/* When a match card is hovered, the VS mini EXPLODES outward the
inverse of its implode entrance. Each part flies away in the direction
opposite to where it came from, clearing the stage for the video.
The whole overlay fades to fully hidden by the time the panels are
off-screen (~0.6s), so the video takes over cleanly. */
.yt-video-card:hover .yt-video-thumb .vs-mini {
animation: vsmFadeOut .55s .25s ease forwards;
}
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-red { animation: vsmExplodeL .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-panel-blue { animation: vsmExplodeR .55s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-red { animation: vsmExplodeInfoL .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-info-blue { animation: vsmExplodeInfoR .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-top { animation: vsmExplodeTop .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-bottom { animation: vsmExplodeBot .5s cubic-bezier(.55,0,.7,.2) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-center { animation: vsmExplodeVS .6s cubic-bezier(.34,1.56,.64,1) forwards !important; }
.yt-video-card:hover .yt-video-thumb .vs-mini .vs-mini-divider { animation: vsmDividerOut .35s ease forwards !important; }
/* Force the hover-preview video above the VS mini's stacking context.
The default rule sets video { z-index: 2 } but the VS mini itself
is z-index 1 + a stacking context, so we bump the video to be safe. */
.yt-video-thumb:has(.vs-mini) video { z-index: 5 !important; background: transparent !important; }
.vs-mini .vs-mini-stage {
position: absolute;
left: 50%; top: 50%;
width: var(--vs-mini-w); height: var(--vs-mini-h);
transform: translate(-50%, -50%) scale(var(--vs-mini-scale));
transform-origin: center center;
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
overflow: hidden;
will-change: transform;
}
/* Animations only run while the .vs-mini-run class is set (removed +
re-added on mouseleave to force a replay from the intro). */
/* ── Panels ── */
.vs-mini .vs-mini-panel { position: absolute; overflow: hidden; opacity: 0; }
.vs-mini .vs-mini-panel-red {
inset: 0 auto 0 0; width: 56%;
background: oklch(0.28 0.09 25);
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
}
.vs-mini .vs-mini-panel-blue {
inset: 0 0 0 auto; width: 56%;
background: oklch(0.28 0.09 255);
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
}
.vs-mini.vs-mini-run .vs-mini-panel-red { animation: vsmPanelL .9s cubic-bezier(.22,1,.36,1) both; }
.vs-mini.vs-mini-run .vs-mini-panel-blue { animation: vsmPanelR .9s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-photo {
position: absolute; inset: 0;
background-size: cover; background-position: center;
animation: vsmDrift 18s ease-in-out infinite;
}
.vs-mini .vs-mini-photo-rev { animation-direction: reverse; }
.vs-mini .vs-mini-scrim-red { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
.vs-mini .vs-mini-scrim-blue { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
.vs-mini .vs-mini-fade {
position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
}
/* ── Divider ── */
.vs-mini .vs-mini-divider {
position: absolute; top: -6%; bottom: -6%; left: 50%;
width: 3px; margin-left: -1.5px;
transform: rotate(10.15deg);
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
pointer-events: none; filter: blur(1px);
}
/* ── Fighter identity (fixed px on 1920×1080 canvas) ── */
.vs-mini .vs-mini-info {
position: absolute; z-index: 6; max-width: 44%;
display: flex; flex-direction: column; gap: 13px;
opacity: 0;
}
.vs-mini .vs-mini-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; }
.vs-mini .vs-mini-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; }
.vs-mini.vs-mini-run .vs-mini-info-red { animation: vsmRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
.vs-mini.vs-mini-run .vs-mini-info-blue { animation: vsmRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-tag {
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
letter-spacing: .35em; color: #fff;
padding: 5.4px 15.1px 5.4px 18.9px;
}
.vs-mini .vs-mini-tag-red { background: oklch(0.55 0.20 25); }
.vs-mini .vs-mini-tag-blue { background: oklch(0.50 0.16 255); }
.vs-mini .vs-mini-flag-row { display: flex; align-items: center; gap: 15.1px; }
.vs-mini .vs-mini-flag-row-r { flex-direction: row-reverse; }
.vs-mini .vs-mini-flag {
width: 56.2px; height: auto; aspect-ratio: 4/3;
background-size: cover !important; background-position: center !important;
border: 1px solid rgba(255,255,255,.35);
box-shadow: 0 4px 18px rgba(0,0,0,.6);
display: inline-block; line-height: 0;
}
.vs-mini .vs-mini-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
.vs-mini .vs-mini-country-red { color: oklch(0.85 0.05 25); }
.vs-mini .vs-mini-country-blue { color: oklch(0.85 0.05 255); }
.vs-mini .vs-mini-name {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 71.3px; line-height: .95;
text-transform: uppercase; color: #fff;
text-shadow: 0 6px 30px rgba(0,0,0,.8);
}
.vs-mini .vs-mini-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
.vs-mini .vs-mini-club-row-r { flex-direction: row-reverse; }
.vs-mini .vs-mini-logo {
width: 69.1px; height: 69.1px;
border-radius: 50%;
background: rgba(255,255,255,.06);
background-size: cover; background-position: center;
border: 1px solid rgba(255,255,255,.2);
}
.vs-mini .vs-mini-club {
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
color: rgba(232,230,224,.9);
}
/* Fighter chips row (Record / Rank / Stats) — matches full-page VS. */
.vs-mini .vs-mini-chips-row { display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px; }
.vs-mini .vs-mini-info-blue .vs-mini-chips-row { justify-content: flex-end; }
.vs-mini .vs-mini-chip-fighter {
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
padding: 5.4px 14px;
background: rgba(10,10,14,.62);
border: 1px solid rgba(255,255,255,.25);
color: #fff;
}
.vs-mini .vs-mini-chip-fighter-gold {
border-color: oklch(0.85 0.16 85 / .6);
color: oklch(0.87 0.14 85);
}
/* ══ Placeholder hiding — parity with the full-page VS. ══ */
.vs-mini .vsm-nullable:empty { display: none; }
.vs-mini .vsm-nullable-hide { display: none; }
.vs-mini .vs-mini-chip.vsm-nullable-chip[data-vsm-empty="1"] { display: none; }
.vs-mini .vs-mini-bottom .vs-mini-diamond-mc:has(+ .vs-mini-chip[data-vsm-empty="1"]),
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-mc,
.vs-mini .vs-mini-bottom .vs-mini-chip[data-vsm-empty="1"] + .vs-mini-diamond-cr,
.vs-mini .vs-mini-bottom .vs-mini-diamond-cr:has(+ .vs-mini-chip[data-vsm-empty="1"]) { display: none; }
/* ── Top block ── */
.vs-mini .vs-mini-top {
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
z-index: 8; width: 92%; pointer-events: none; opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-top { animation: vsmDropIn .8s .5s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-top-event {
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
letter-spacing: .42em; text-transform: uppercase;
color: rgba(232,230,224,.92); text-align: center;
text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
.vs-mini .vs-mini-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
.vs-mini .vs-mini-top-line { height: 2px; width: 64.8px; }
.vs-mini .vs-mini-top-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
.vs-mini .vs-mini-top-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
.vs-mini .vs-mini-top-stage {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 38.9px;
letter-spacing: .30em; padding-left: .3em;
color: oklch(0.85 0.16 85); text-transform: uppercase;
}
.vs-mini .vs-mini-top-weight {
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
color: #fff; text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
/* ── Center VS ── */
.vs-mini .vs-mini-center {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
z-index: 7; pointer-events: none;
display: flex; align-items: center; justify-content: center;
opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-center { animation: vsmSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-word {
position: relative;
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 183.6px; font-style: italic;
color: #fffdf5; line-height: 1;
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
animation: vsmPulse 2.4s ease-in-out infinite;
overflow: visible;
}
.vs-mini .vs-mini-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
.vs-mini .vs-mini-shine {
position: absolute; top: 0; bottom: 0; width: 34%;
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
animation: vsmShine 5s ease-in-out infinite;
}
/* .vs-mini-flash rules removed with the element itself. */
/* ── Bottom chips ── */
.vs-mini .vs-mini-bottom {
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
display: flex; gap: 17.3px; z-index: 8; align-items: center;
flex-wrap: wrap; justify-content: center; max-width: 94%; opacity: 0;
}
.vs-mini.vs-mini-run .vs-mini-bottom { animation: vsmRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both; }
.vs-mini .vs-mini-chip {
display: flex; align-items: baseline; gap: 8.6px;
background: rgba(10,10,14,.72);
border: 1px solid oklch(0.85 0.16 85 / .45);
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
}
.vs-mini .vs-mini-chip-lbl {
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .30em; text-transform: uppercase;
color: rgba(232,230,224,.65);
}
.vs-mini .vs-mini-chip-val {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 34.6px; color: #fff;
}
.vs-mini .vs-mini-diamond {
width: 6px; height: 6px; transform: rotate(45deg);
background: oklch(0.85 0.16 85);
}
/* ── Portrait canvas (rare — vertical match cards) ── */
.vs-mini.vs-mini-portrait { --vs-mini-w: 1080px; --vs-mini-h: 1920px; }
.vs-mini.vs-mini-portrait .vs-mini-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); }
.vs-mini.vs-mini-portrait .vs-mini-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); }
.vs-mini.vs-mini-portrait .vs-mini-divider {
left: -4%; right: -4%; top: 50%; bottom: auto;
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
transform: rotate(-4.48deg);
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
}
.vs-mini.vs-mini-portrait .vs-mini-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
.vs-mini.vs-mini-portrait .vs-mini-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-red { animation-name: vsmPanelT; }
.vs-mini.vs-mini-portrait.vs-mini-run .vs-mini-panel-blue { animation-name: vsmPanelB; }
/* Hide the shimmer skeleton on match cards — the VS mini fills the thumb. */
.yt-video-thumb:has(.vs-mini)::before { display: none; }
/* Animations */
@keyframes vsmPulse {
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
}
@keyframes vsmShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
@keyframes vsmDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
@keyframes vsmPanelL { from { opacity: 1; transform: translateX(-105%); } to { opacity: 1; transform: translateX(0); } }
@keyframes vsmPanelR { from { opacity: 1; transform: translateX( 105%); } to { opacity: 1; transform: translateX(0); } }
@keyframes vsmPanelT { from { opacity: 1; transform: translateY(-105%); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmPanelB { from { opacity: 1; transform: translateY( 105%); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsmRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsmDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsmSlam {
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
80% { transform: translate(-50%,-52%) scale(1.06); }
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
}
/* ══ EXPLODE the inverse of the implode entrance. Each piece flies
OUT in the direction opposite to where it came from, then the
whole mini fades to zero opacity while the video takes the stage. ══ */
@keyframes vsmFadeOut { to { opacity: 0; } }
@keyframes vsmExplodeL { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX(-140%) scale(1.05); } }
@keyframes vsmExplodeR { from { opacity: 1; transform: translateX(0) scale(1); } to { opacity: 0; transform: translateX( 140%) scale(1.05); } }
@keyframes vsmExplodeInfoL { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate(-70%, 40%) scale(.85); } }
@keyframes vsmExplodeInfoR { from { opacity: 1; transform: translate(0,0) scale(1); } to { opacity: 0; transform: translate( 70%, 40%) scale(.85); } }
@keyframes vsmExplodeTop { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%,-120%) scale(.9); } }
@keyframes vsmExplodeBot { from { opacity: 1; transform: translate(-50%,0) scale(1); } to { opacity: 0; transform: translate(-50%, 120%) scale(.9); } }
@keyframes vsmExplodeVS {
0% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); filter: blur(0); }
40% { opacity: 1; transform: translate(-50%,-52%) scale(1.3) rotate(-2deg); filter: blur(1px); }
100% { opacity: 0; transform: translate(-50%,-52%) scale(4) rotate(6deg); filter: blur(8px); }
}
@keyframes vsmDividerOut { to { opacity: 0; transform: rotate(10.15deg) scaleY(0); } }
</style>
<script>
(function () {
if (window._vsMiniBound) return;
window._vsMiniBound = true;
// Fit the 1920×1080 (or 1080×1920) stage to whatever thumb size the
// card resolves to. One ResizeObserver watches every current + future
// thumb — cheap because the callback is a couple of arithmetic ops.
function fit(el) {
const host = el.parentElement;
if (!host) return;
const w = host.clientWidth, h = host.clientHeight;
if (!w || !h) return;
const portrait = h > w * 1.05;
el.classList.toggle('vs-mini-portrait', portrait);
const sw = portrait ? 1080 : 1920;
const sh = portrait ? 1920 : 1080;
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh));
}
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
for (const e of entries) {
const el = e.target.querySelector(':scope > .vs-mini');
if (el) fit(el);
}
}) : null;
function register(el) {
fit(el);
if (ro) ro.observe(el.parentElement);
}
// Register everything already in the DOM.
document.querySelectorAll('.vs-mini').forEach(register);
// Register anything added later (SPA nav, infinite-scroll loads).
new MutationObserver(muts => {
for (const m of muts) {
for (const n of m.addedNodes) {
if (!(n instanceof Element)) continue;
if (n.matches?.('.vs-mini')) register(n);
n.querySelectorAll?.('.vs-mini').forEach(register);
}
}
}).observe(document.body, { childList: true, subtree: true });
// Restart entrance animations whenever the mouse leaves a card thumb
// that has a VS mini in it. removing + re-adding the class in the
// next frame forces the CSS animation to play from the start.
document.addEventListener('mouseleave', (e) => {
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
if (!thumb) return;
const vsm = thumb.querySelector(':scope > .vs-mini');
if (!vsm) return;
vsm.classList.remove('vs-mini-run');
// Reflow-then-reapply — CSS animations only restart when the
// running class is removed and reintroduced across a frame.
void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
})();
</script>
@endonce

View File

@ -54,11 +54,21 @@ $sizeClasses = match($size) {
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)" <div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}"> data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')"> <img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.vs-mini', ['video' => $video])
@endif
@if($videoUrl) @if($videoUrl)
<video preload="none"> {{-- Match cards preload metadata so the first frame is ready the
instant the user hovers (otherwise the browser fetches, buffers
then paints showing a black frame behind the fade). Other
card types keep preload="none" to save bandwidth. --}}
<video preload="{{ $video && $video->type === 'match' ? 'metadata' : 'none' }}" playsinline>
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}"> <source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video> </video>
@endif @endif
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.scorebar-mini', ['video' => $video])
@endif
{{-- Equalizer overlay shown when audio-only track is previewing --}} {{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay"> <div class="audio-preview-overlay">
<div class="audio-eq"> <div class="audio-eq">

View File

@ -879,8 +879,12 @@
clearErrors(); clearErrors();
lockButtons(true); lockButtons(true);
// Edit mode → just update the record. Create mode → upload the video first. // Skip the video-upload step when:
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst(); // - editing an existing match (matchId present), OR
// - creating a match against an already-attached video (videoId present via
// attachExistingVideo, e.g. clicking "Edit" on a match-type video that has
// no SportsMatch record yet — the video is real, we're just adding match data).
const chain = (idInput.value || videoIdInp.value) ? Promise.resolve() : uploadVideoFirst();
chain chain
.then(() => postMatch(intent)) .then(() => postMatch(intent))
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); }) .catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })

View File

@ -0,0 +1,559 @@
{{-- ══════════════════════════════════════════════════════════════════════
VS INTRO SCREEN arena card overlaid on the first 5 s of the match.
Rendered as a FIXED 1920×1080 (landscape) or 1080×1920 (portrait)
canvas that is transform-scaled to fit the parent player box. Every
child uses ABSOLUTE PIXELS on that canvas so the layout is identical
at every zoom level and never scales off the viewport width.
══════════════════════════════════════════════════════════════════════ --}}
@php
$vsRed = $hd['red'] ?? null;
$vsBlue = $hd['blue'] ?? null;
$vsShow = $hd && (($vsRed['name'] ?? null) || ($vsBlue['name'] ?? null));
$vsFlagBg = fn(?string $c) => (($c = strtolower(trim((string) $c))) !== '' ? $c : 'xx');
$vsCountryName = fn (?string $code) => \App\Data\Countries::name($code);
$vsImg = fn(?string $path) => $path ? route('media.thumbnail', $path) : null;
// Every placeholder from the artifact renders regardless of whether the
// data exists — CSS `.vs-nullable:empty { display: none }` hides any
// element that came back empty, so the layout adapts but the DOM shape
// matches the original design file 1:1.
$vsStr = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
$vsEvent = $vsStr($hd['championship'] ?? '');
$vsStage = $vsStr($hd['division'] ?? '') ?: $vsStr($hd['format'] ?? '');
$vsWeight = $vsStr($hd['weight_category'] ?? '');
$vsMatchNo = $vsStr($hd['match_number'] ?? '');
$vsCourt = $vsStr($hd['court'] ?? '');
$vsRef = $vsStr($hd['referee']['name'] ?? '');
$vsRefFlag = $vsStr($hd['referee']['flag'] ?? '');
$vsVenue = $vsStr($hd['venue']['name'] ?? '');
// Extra per-fighter chips from the artifact (record / rank / stats).
// headerData() doesn't expose them today; fall back to the raw
// participants JSON so the placeholders wire up the moment the fields
// start being written by the uploader form.
$vsParts = $video->sportsMatch?->participants ?? [];
$vsRedRecord = $vsStr($vsParts['p2_record'] ?? '');
$vsRedRank = $vsStr($vsParts['p2_rank'] ?? '');
$vsRedStats = $vsStr($vsParts['p2_stats'] ?? '');
$vsBlueRecord = $vsStr($vsParts['p1_record'] ?? '');
$vsBlueRank = $vsStr($vsParts['p1_rank'] ?? '');
$vsBlueStats = $vsStr($vsParts['p1_stats'] ?? '');
// Fighter identity strings.
$vsRedName = $vsStr($vsRed['name'] ?? '');
$vsBlueName = $vsStr($vsBlue['name'] ?? '');
$vsRedClub = $vsStr($vsRed['club'] ?? '');
$vsBlueClub = $vsStr($vsBlue['club'] ?? '');
$vsRedFlag = $vsStr($vsRed['flag'] ?? '');
$vsBlueFlag = $vsStr($vsBlue['flag'] ?? '');
$vsRedLogo = $vsImg($vsRed['club_logo'] ?? null);
$vsBlueLogo = $vsImg($vsBlue['club_logo'] ?? null);
@endphp
@if ($vsShow)
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
<div id="vsScreen" class="vs-screen" role="dialog" aria-label="Match introduction">
<button type="button" class="vs-skip" id="vsSkip" aria-label="Skip intro">
Skip <span id="vsSkipTimer">6</span>
</button>
{{-- Fixed 1920×1080 (or 1080×1920 in portrait) canvas.
JS sets --vs-scale on #vsScreen to fit whichever parent this sits in. --}}
<div class="vs-stage">
{{-- RED side panel --}}
<div class="vs-panel vs-panel-red">
<div class="vs-panel-photo vs-panel-photo-red"
@if($vsImg($vsRed['headshot'] ?? null)) style="background-image:url('{{ $vsImg($vsRed['headshot']) }}')"@endif></div>
<div class="vs-panel-scrim vs-panel-scrim-red"></div>
<div class="vs-panel-gradient"></div>
</div>
{{-- BLUE side panel --}}
<div class="vs-panel vs-panel-blue">
<div class="vs-panel-photo vs-panel-photo-blue"
@if($vsImg($vsBlue['headshot'] ?? null)) style="background-image:url('{{ $vsImg($vsBlue['headshot']) }}')"@endif></div>
<div class="vs-panel-scrim vs-panel-scrim-blue"></div>
<div class="vs-panel-gradient"></div>
</div>
{{-- Center divider glow line --}}
<div class="vs-divider"></div>
{{-- ══ RED fighter identity (left) every placeholder from the
original artifact is always in the DOM. Empty ones are hidden
by `.vs-nullable:empty { display: none }` in the CSS below. ══ --}}
<div class="vs-info vs-info-red">
<div class="vs-corner-tag vs-corner-tag-red">AKA · RED</div>
<div class="vs-flag-row vs-nullable-row">
<span class="vs-flag fi fi-{{ $vsFlagBg($vsRedFlag) }} vs-flag-slot {{ $vsRedFlag === '' ? 'vs-nullable-hide' : '' }}"></span>
<div class="vs-country vs-country-red vs-nullable">{{ $vsRedFlag !== '' ? strtoupper($vsCountryName($vsRedFlag) ?? $vsRedFlag) : '' }}</div>
</div>
<div class="vs-name vs-nullable">{{ $vsRedName }}</div>
<div class="vs-club-row vs-nullable-row">
<div class="vs-club-logo vs-club-logo-slot {{ $vsRedLogo ? '' : 'vs-nullable-hide' }}"
@if($vsRedLogo) style="background-image:url('{{ $vsRedLogo }}')"@endif></div>
<div class="vs-club vs-nullable">{{ $vsRedClub }}</div>
</div>
<div class="vs-chips-row vs-nullable-row">
<div class="vs-chip-fighter vs-nullable">{{ $vsRedRecord }}</div>
<div class="vs-chip-fighter vs-chip-fighter-gold vs-nullable">{{ $vsRedRank }}</div>
<div class="vs-chip-fighter vs-nullable">{{ $vsRedStats }}</div>
</div>
</div>
{{-- ══ BLUE fighter identity (right) ══ --}}
<div class="vs-info vs-info-blue">
<div class="vs-corner-tag vs-corner-tag-blue">AO · BLUE</div>
<div class="vs-flag-row vs-flag-row-r vs-nullable-row">
<span class="vs-flag fi fi-{{ $vsFlagBg($vsBlueFlag) }} vs-flag-slot {{ $vsBlueFlag === '' ? 'vs-nullable-hide' : '' }}"></span>
<div class="vs-country vs-country-blue vs-nullable">{{ $vsBlueFlag !== '' ? strtoupper($vsCountryName($vsBlueFlag) ?? $vsBlueFlag) : '' }}</div>
</div>
<div class="vs-name vs-nullable">{{ $vsBlueName }}</div>
<div class="vs-club-row vs-club-row-r vs-nullable-row">
<div class="vs-club-logo vs-club-logo-slot {{ $vsBlueLogo ? '' : 'vs-nullable-hide' }}"
@if($vsBlueLogo) style="background-image:url('{{ $vsBlueLogo }}')"@endif></div>
<div class="vs-club vs-nullable">{{ $vsBlueClub }}</div>
</div>
<div class="vs-chips-row vs-nullable-row">
<div class="vs-chip-fighter vs-nullable">{{ $vsBlueRecord }}</div>
<div class="vs-chip-fighter vs-chip-fighter-gold vs-nullable">{{ $vsBlueRank }}</div>
<div class="vs-chip-fighter vs-nullable">{{ $vsBlueStats }}</div>
</div>
</div>
{{-- Top: event + stage + weight (all three always in DOM) --}}
<div class="vs-top">
<div class="vs-top-event vs-nullable">{{ $vsEvent }}</div>
<div class="vs-top-stage-row {{ $vsStage === '' ? 'vs-nullable-hide' : '' }}">
<div class="vs-top-stage-line vs-top-stage-line-l"></div>
<div class="vs-top-stage">{{ $vsStage }}</div>
<div class="vs-top-stage-line vs-top-stage-line-r"></div>
</div>
<div class="vs-top-weight vs-nullable">{{ $vsWeight }}</div>
</div>
{{-- Center VS --}}
<div class="vs-center">
<div class="vs-word">VS
<div class="vs-shine-wrap"><div class="vs-shine"></div></div>
</div>
</div>
<div class="vs-flash"></div>
{{-- Bottom: match + court + referee chips. All three chips + both
diamond separators are always in the DOM; each chip hides when
its value is empty and each diamond hides when either neighbour
is hidden (via :has() below). --}}
<div class="vs-bottom">
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsMatchNo === '' ? '1' : '0' }}">
<span class="vs-chip-label">Match</span>
<span class="vs-chip-value vs-nullable">{{ $vsMatchNo }}</span>
</div>
<div class="vs-diamond vs-diamond-match-court"></div>
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsCourt === '' ? '1' : '0' }}">
<span class="vs-chip-label">Court</span>
<span class="vs-chip-value vs-nullable">{{ $vsCourt }}</span>
</div>
<div class="vs-diamond vs-diamond-court-ref"></div>
<div class="vs-chip vs-nullable-chip" data-vs-empty="{{ $vsRef === '' ? '1' : '0' }}">
<span class="vs-chip-label">Referee</span>
<span class="vs-chip-value vs-chip-value-name vs-nullable">{{ $vsRef }}</span>
</div>
</div>
</div>
</div>
<style>
/* ═══════════════════════════════════════════════════════════════════════
Fixed 1920×1080 canvas, transform-scaled to fit its parent (.ytp
inside the player, or .vs-preview-stage on the preview page). Every
child dimension is in ABSOLUTE PIXELS on that canvas never vw/vh
so the layout is identical whether the box is 400 px or 4000 px wide.
JS writes --vs-scale via ResizeObserver on the parent.
═══════════════════════════════════════════════════════════════════════ */
#vsScreen.vs-screen {
position: absolute; inset: 0; z-index: 40;
background: #050507;
font-family: 'Barlow Condensed', sans-serif;
color: #e8e6e0;
overflow: hidden;
opacity: 1;
transition: opacity .35s ease;
--vs-scale: 1;
--vs-stage-w: 1920px;
--vs-stage-h: 1080px;
}
#vsScreen.vs-hide { opacity: 0; pointer-events: none; }
#vsScreen .vs-stage {
position: absolute;
left: 50%; top: 50%;
width: var(--vs-stage-w); height: var(--vs-stage-h);
transform: translate(-50%, -50%) scale(var(--vs-scale));
transform-origin: center center;
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
overflow: hidden;
will-change: transform;
}
/* Skip button lives OUTSIDE the scaled stage so it stays at real pixels
in the top-right of the video area at any container size. */
#vsScreen .vs-skip {
position: absolute; top: 12px; right: 14px; z-index: 50;
background: rgba(10,10,14,.72);
color: #fff; border: 1px solid rgba(255,255,255,.28);
padding: 6px 14px;
font: 700 12px/1 'Barlow Condensed', sans-serif;
letter-spacing: .22em; text-transform: uppercase;
cursor: pointer;
backdrop-filter: blur(6px);
transition: background .15s, border-color .15s;
}
#vsScreen .vs-skip:hover { background: rgba(10,10,14,.92); border-color: rgba(255,255,255,.55); }
#vsScreen .vs-skip #vsSkipTimer { display: inline-block; margin-left: 6px; opacity: .8; }
/* ══ EVERYTHING BELOW: absolute pixels on the 1920×1080 canvas ══ */
/* ── Side panels ── */
#vsScreen .vs-panel { position: absolute; overflow: hidden; }
#vsScreen .vs-panel-red {
inset: 0 auto 0 0; width: 56%;
background: oklch(0.28 0.09 25);
clip-path: polygon(0 0, 100% 0, 82% 100%, 0 100%);
animation: vsPanelL .9s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-panel-blue {
inset: 0 0 0 auto; width: 56%;
background: oklch(0.28 0.09 255);
clip-path: polygon(18% 0, 100% 0, 100% 100%, 0 100%);
animation: vsPanelR .9s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-panel-photo {
position: absolute; inset: 0;
background-size: cover; background-position: center;
animation: vsSlowDrift 18s ease-in-out infinite;
}
#vsScreen .vs-panel-photo-blue { animation-direction: reverse; }
#vsScreen .vs-panel-scrim-red { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, oklch(0.45 0.18 25 / 0.55) 0%, transparent 55%); }
#vsScreen .vs-panel-scrim-blue { position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(245deg, oklch(0.45 0.15 255 / 0.55) 0%, transparent 55%); }
#vsScreen .vs-panel-gradient {
position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(to top, rgba(5,5,7,.95) 0%, rgba(5,5,7,.72) 22%, rgba(5,5,7,.30) 42%, transparent 62%),
linear-gradient(to bottom, rgba(5,5,7,.82) 0%, rgba(5,5,7,.35) 18%, transparent 32%);
}
/* ── Divider ── */
#vsScreen .vs-divider {
position: absolute; top: -6%; bottom: -6%; left: 50%;
width: 3px; margin-left: -1.5px;
transform: rotate(10.15deg);
background: linear-gradient(to bottom, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
pointer-events: none; filter: blur(1px);
}
/* ── Fighter identity blocks ── (absolute px on the 1920×1080 canvas) */
#vsScreen .vs-info {
position: absolute; z-index: 6; max-width: 44%;
display: flex; flex-direction: column; gap: 13px;
}
#vsScreen .vs-info-red { left: 43.2px; bottom: 118.8px; align-items: flex-start; animation: vsRiseUp .8s .7s cubic-bezier(.22,1,.36,1) both; }
#vsScreen .vs-info-blue { right: 43.2px; bottom: 118.8px; align-items: flex-end; text-align: right; animation: vsRiseUp .8s .85s cubic-bezier(.22,1,.36,1) both; }
#vsScreen .vs-corner-tag {
font: 800 21.6px/1 'Barlow Condensed', sans-serif;
letter-spacing: .35em; color: #fff;
padding: 5.4px 15.1px 5.4px 18.9px;
}
#vsScreen .vs-corner-tag-red { background: oklch(0.55 0.20 25); }
#vsScreen .vs-corner-tag-blue { background: oklch(0.50 0.16 255); }
#vsScreen .vs-flag-row { display: flex; align-items: center; gap: 15.1px; }
#vsScreen .vs-flag-row-r { flex-direction: row-reverse; }
#vsScreen .vs-flag {
width: 56.2px; height: auto; aspect-ratio: 4/3;
background-size: cover !important; background-position: center !important;
border: 1px solid rgba(255,255,255,.35);
box-shadow: 0 4px 18px rgba(0,0,0,.6);
display: inline-block; line-height: 0;
}
#vsScreen .vs-country { font: 700 32.4px/1 'Barlow Condensed', sans-serif; letter-spacing: .28em; }
#vsScreen .vs-country-red { color: oklch(0.85 0.05 25); }
#vsScreen .vs-country-blue { color: oklch(0.85 0.05 255); }
#vsScreen .vs-name {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 71.3px; line-height: .95;
text-transform: uppercase; color: #fff;
text-shadow: 0 6px 30px rgba(0,0,0,.8);
}
#vsScreen .vs-club-row { display: flex; align-items: center; gap: 13px; margin-top: 4.3px; }
#vsScreen .vs-club-row-r { flex-direction: row-reverse; }
#vsScreen .vs-club-logo {
width: 69.1px; height: 69.1px;
border-radius: 50%;
background: rgba(255,255,255,.06);
background-size: cover; background-position: center;
border: 1px solid rgba(255,255,255,.2);
}
#vsScreen .vs-club {
font: 600 30.2px/1.1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
color: rgba(232,230,224,.9);
}
/* Fighter chips row (Record / Rank / Stats) — from the original artifact. */
#vsScreen .vs-chips-row {
display: flex; flex-wrap: wrap; gap: 9.7px; margin-top: 5.4px;
}
#vsScreen .vs-info-blue .vs-chips-row { justify-content: flex-end; }
#vsScreen .vs-chip-fighter {
font: 700 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .12em; text-transform: uppercase;
padding: 5.4px 14px;
background: rgba(10,10,14,.62);
border: 1px solid rgba(255,255,255,.25);
color: #fff;
}
#vsScreen .vs-chip-fighter-gold {
border-color: oklch(0.85 0.16 85 / .6);
color: oklch(0.87 0.14 85);
}
/* ══ Placeholder hiding ══
Every placeholder DOM element is always rendered. Empty ones are hidden
here this keeps the design's HTML shape intact (same as the artifact)
while adapting the visible layout to whatever data actually exists. */
#vsScreen .vs-nullable:empty { display: none; }
/* When every direct child of a "row" wrapper is hidden, the row collapses too. */
#vsScreen .vs-nullable-row:not(:has(> :not(.vs-nullable-hide):not(.vs-nullable:empty))) { display: none; }
/* Image slots explicitly marked as missing (flag / club logo without data). */
#vsScreen .vs-nullable-hide { display: none; }
/* Bottom chips: whole chip hides when its value is empty, and the
diamond separators next to a hidden chip hide too. */
#vsScreen .vs-chip.vs-nullable-chip[data-vs-empty="1"] { display: none; }
#vsScreen .vs-bottom .vs-diamond-match-court:has(+ .vs-chip[data-vs-empty="1"]),
#vsScreen .vs-bottom .vs-chip[data-vs-empty="1"] + .vs-diamond-court-ref,
#vsScreen .vs-bottom .vs-chip[data-vs-empty="1"] + .vs-diamond-match-court,
#vsScreen .vs-bottom .vs-diamond-court-ref:has(+ .vs-chip[data-vs-empty="1"]) { display: none; }
/* ── Top (event / stage / weight) ── */
#vsScreen .vs-top {
position: absolute; top: 34.6px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 10.8px;
z-index: 8; width: 92%; pointer-events: none;
animation: vsDropIn .8s .5s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-top-event {
font: 700 30.2px/1.05 'Barlow Condensed', sans-serif;
letter-spacing: .42em; text-transform: uppercase;
color: rgba(232,230,224,.92); text-align: center;
text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
#vsScreen .vs-top-stage-row { display: flex; align-items: center; gap: 17.3px; }
#vsScreen .vs-top-stage-line { height: 2px; width: 64.8px; }
#vsScreen .vs-top-stage-line-l { background: linear-gradient(to left, oklch(0.85 0.16 85), transparent); }
#vsScreen .vs-top-stage-line-r { background: linear-gradient(to right, oklch(0.85 0.16 85), transparent); }
#vsScreen .vs-top-stage {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 38.9px;
letter-spacing: .30em; padding-left: .3em;
color: oklch(0.85 0.16 85); text-transform: uppercase;
}
#vsScreen .vs-top-weight {
font: 700 42px/1.1 'Anton', 'Barlow Condensed', sans-serif;
letter-spacing: .28em; padding-left: .28em; text-transform: uppercase;
color: #ffffff;
text-shadow: 0 2px 14px rgba(0,0,0,.9);
}
/* ── Center VS ── */
#vsScreen .vs-center {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -52%);
z-index: 7; pointer-events: none;
display: flex; align-items: center; justify-content: center;
animation: vsSlam .7s 1.1s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-word {
position: relative;
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 183.6px;
font-style: italic;
color: #fffdf5; line-height: 1;
-webkit-text-stroke: 2px oklch(0.85 0.16 85 / 0.6);
animation: vsPulse 2.4s ease-in-out infinite;
overflow: visible;
}
#vsScreen .vs-shine-wrap { position: absolute; inset: -10% -20%; overflow: hidden; pointer-events: none; }
#vsScreen .vs-shine {
position: absolute; top: 0; bottom: 0; width: 34%;
background: linear-gradient(to right, transparent, rgba(255,255,255,.16), transparent);
animation: vsShine 5s ease-in-out infinite;
}
#vsScreen .vs-flash {
position: absolute; inset: 0; background: #fff;
opacity: 0; pointer-events: none; z-index: 9;
animation: vsFlash .9s 1.5s ease-out both;
}
/* ── Bottom chips ── */
#vsScreen .vs-bottom {
position: absolute; bottom: 32.4px; left: 50%; transform: translateX(-50%);
display: flex; gap: 17.3px; z-index: 8; align-items: center;
flex-wrap: wrap; justify-content: center; max-width: 94%;
animation: vsRiseC .8s 1.3s cubic-bezier(.22,1,.36,1) both;
}
#vsScreen .vs-chip {
display: flex; align-items: baseline; gap: 8.6px;
background: rgba(10,10,14,.72);
border: 1px solid oklch(0.85 0.16 85 / .45);
padding: 10.8px 23.8px; backdrop-filter: blur(6px);
}
#vsScreen .vs-chip-label {
font: 600 23.8px/1 'Barlow Condensed', sans-serif;
letter-spacing: .30em; text-transform: uppercase;
color: rgba(232,230,224,.65);
}
#vsScreen .vs-chip-value {
font-family: 'Anton', 'Barlow Condensed', sans-serif;
font-size: 34.6px; color: #fff;
}
#vsScreen .vs-chip-value-name {
font-family: 'Barlow Condensed', sans-serif;
font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
font-size: 28.1px;
}
#vsScreen .vs-diamond {
width: 6px; height: 6px; transform: rotate(45deg);
background: oklch(0.85 0.16 85);
}
/* ── Portrait canvas (1080×1920): stack layout ── */
#vsScreen.vs-portrait { --vs-stage-w: 1080px; --vs-stage-h: 1920px; }
#vsScreen.vs-portrait .vs-panel-red { inset: 0 0 auto 0; width: 100%; height: 56%;
clip-path: polygon(0 0, 100% 0, 100% 82%, 0 96%); animation-name: vsPanelT; }
#vsScreen.vs-portrait .vs-panel-blue { inset: auto 0 0 0; width: 100%; height: 56%;
clip-path: polygon(0 18%, 100% 4%, 100% 100%, 0 100%); animation-name: vsPanelB; }
#vsScreen.vs-portrait .vs-divider {
left: -4%; right: -4%; top: 50%; bottom: auto;
width: auto; height: 3px; margin-top: -1.5px; margin-left: 0;
transform: rotate(-4.48deg);
background: linear-gradient(to right, transparent, oklch(0.85 0.16 85 / .9) 20%, oklch(0.85 0.16 85 / .9) 80%, transparent);
}
#vsScreen.vs-portrait .vs-info-red { left: 43.2px; top: 129.6px; bottom: auto; }
#vsScreen.vs-portrait .vs-info-blue { right: 43.2px; bottom: 129.6px; top: auto; }
/* ── Animations ── */
@keyframes vsPulse {
0%,100% { text-shadow: 0 0 30px rgba(255,215,120,.55), 0 0 90px rgba(255,170,60,.3); transform: scale(1); }
50% { text-shadow: 0 0 65px rgba(255,220,130,1), 0 0 160px rgba(255,170,60,.7); transform: scale(1.045); }
}
@keyframes vsShine { 0% { transform: translateX(-130%) skewX(-18deg); } 60%,100% { transform: translateX(230%) skewX(-18deg); } }
@keyframes vsSlowDrift { 0% { transform: translate3d(0,0,0) scale(1.02); } 50% { transform: translate3d(0,-1.2%,0) scale(1.05); } 100% { transform: translate3d(0,0,0) scale(1.02); } }
@keyframes vsPanelL { from { transform: translateX(-105%); } to { transform: translateX(0); } }
@keyframes vsPanelR { from { transform: translateX( 105%); } to { transform: translateX(0); } }
@keyframes vsPanelT { from { transform: translateY(-105%); } to { transform: translateY(0); } }
@keyframes vsPanelB { from { transform: translateY( 105%); } to { transform: translateY(0); } }
@keyframes vsRiseUp { from { opacity: 0; transform: translateY(43.2px); } to { opacity: 1; transform: translateY(0); } }
@keyframes vsRiseC { from { opacity: 0; transform: translate(-50%, 43.2px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsDropIn { from { opacity: 0; transform: translate(-50%, -32.4px); } to { opacity: 1; transform: translate(-50%, 0); } }
@keyframes vsSlam {
0% { opacity: 0; transform: translate(-50%,-52%) scale(3.4) rotate(-6deg); }
60% { opacity: 1; transform: translate(-50%,-52%) scale(.92) rotate(1deg); }
80% { transform: translate(-50%,-52%) scale(1.06); }
100% { opacity: 1; transform: translate(-50%,-52%) scale(1) rotate(0deg); }
}
@keyframes vsFlash { 0% { opacity: 0; } 12% { opacity: .85; } 100% { opacity: 0; } }
</style>
<script>
(function () {
const VS_INTRO_SECONDS = 6;
const vs = document.getElementById('vsScreen');
if (!vs) return;
const video = document.getElementById('videoPlayer');
const timerEl = document.getElementById('vsSkipTimer');
let hidden = false, tickInt = null;
// ── Fit the fixed 1920×1080 (or 1080×1920) canvas to the parent box.
function fit() {
const host = vs.parentElement;
if (!host) return;
const w = host.clientWidth, h = host.clientHeight;
if (!w || !h) return;
const portrait = h > w * 1.05;
vs.classList.toggle('vs-portrait', portrait);
const sw = portrait ? 1080 : 1920;
const sh = portrait ? 1920 : 1080;
const scale = Math.min(w / sw, h / sh);
vs.style.setProperty('--vs-scale', scale);
}
fit();
if (window.ResizeObserver && vs.parentElement) {
new ResizeObserver(fit).observe(vs.parentElement);
} else {
window.addEventListener('resize', fit);
}
// ── Hold the video at t=0 while the intro counts down ──────────
// Autoplay may already have kicked in before this script ran; pause
// it, keep it pinned at the start, and only release it at 0.
function pinVideo() {
if (!video) return;
try { video.pause(); } catch (_) {}
try { video.currentTime = 0; } catch (_) {}
}
pinVideo();
// Some browsers fire 'play' immediately after we pause (autoplay
// retry). Re-pause until the intro finishes.
const playGuard = () => { if (!hidden) pinVideo(); };
if (video) video.addEventListener('play', playGuard);
function releaseVideo() {
if (!video) return;
video.removeEventListener('play', playGuard);
try { video.currentTime = 0; } catch (_) {}
const p = video.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
}
// ── Dismiss overlay + start playback ───────────────────────────
function hide() {
if (hidden) return;
hidden = true;
if (tickInt) { clearInterval(tickInt); tickInt = null; }
releaseVideo();
vs.classList.add('vs-hide');
setTimeout(() => { vs.remove(); }, 400);
}
document.getElementById('vsSkip')?.addEventListener('click', (e) => {
e.stopPropagation(); e.preventDefault(); hide();
});
// ── Real-time countdown, independent of video timeline ─────────
const started = performance.now();
function tick() {
const elapsed = (performance.now() - started) / 1000;
const left = Math.max(0, VS_INTRO_SECONDS - elapsed);
if (timerEl) timerEl.textContent = Math.ceil(left);
if (left <= 0) hide();
}
tick();
tickInt = setInterval(tick, 100);
})();
</script>
@endif

View File

@ -2230,8 +2230,10 @@
<span class="replay-badge-word">REPLAY</span> <span class="replay-badge-word">REPLAY</span>
<span class="replay-badge-speed" id="replayBadgeSpeed">×1</span> <span class="replay-badge-speed" id="replayBadgeSpeed">×1</span>
</div> </div>
{{-- Karate/Taekwondo scoreboard overlay + VS intro + gear-menu toggles --}} {{-- Karate/Taekwondo scoreboard overlay + gear-menu toggles --}}
@include('videos.partials.match.scoreboard.index') @include('videos.partials.match.scoreboard.index')
{{-- VS intro card first-play arena screen (skippable) --}}
@include('videos.partials.match.vs.index')
</x-slot:overlay> </x-slot:overlay>
</x-video-player> </x-video-player>

View File

@ -0,0 +1,38 @@
{{-- Standalone preview page for the VS intro overlay. Renders the same
partial that appears over the match player, in a fixed 16:9 stage
centered on a blank page, with no video element behind it. --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VS Preview {{ $video->title }}</title>
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
html, body { margin: 0; padding: 0; background: #050507; height: 100%; overflow: hidden; }
body { display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; }
.vs-preview-stage {
position: relative;
width: min(100vw, calc(100vh * 16 / 9));
aspect-ratio: 16 / 9;
background: #000;
box-shadow: 0 30px 80px rgba(0,0,0,.6);
}
/* The partial's #vsScreen uses position:absolute + inset:0, so it fills
the stage exactly like it does inside .ytp on the real player. */
#videoPlayer { display: none; } /* partial's script looks up this id, keep it inert */
</style>
</head>
<body>
{{-- The partial's script pauses/plays #videoPlayer to hold playback
until the countdown ends. On the preview page there is no source
to play, but the countdown itself is real-time so the overlay
still ticks 5 0 and dismisses correctly. --}}
<video id="videoPlayer" preload="none"></video>
<div class="vs-preview-stage">
@php $hd = $video->sportsMatch->headerData(); @endphp
@include('videos.partials.match.vs.index')
</div>
</body>
</html>

View File

@ -40,6 +40,10 @@ Route::get('/videos/{numericId}', function ($numericId) {
Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken'); Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken');
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show'); Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
Route::get('/videos/{video}/vs-preview', function (\App\Models\Video $video) {
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
return view('videos.vs-preview', ['video' => $video]);
})->name('videos.vsPreview');
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream'); Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
Route::get('/videos/{video}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track'); Route::get('/videos/{video}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track');
Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls'); Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls');