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:
parent
5b6bd86d70
commit
04d63cccdb
@ -8,6 +8,18 @@ class Countries
|
||||
* All countries keyed by ISO2 code.
|
||||
* 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
|
||||
{
|
||||
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
||||
|
||||
@ -506,7 +506,7 @@ class SuperAdminController extends Controller
|
||||
foreach ($uniqueViewers as $viewer) {
|
||||
if (!$viewer->country) continue;
|
||||
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']++;
|
||||
}
|
||||
|
||||
@ -3383,7 +3383,7 @@ class VideoController extends Controller
|
||||
$totalGeo = $rawCountries->sum('cnt');
|
||||
$countries = $rawCountries->map(fn ($c) => [
|
||||
'code' => $c->country,
|
||||
'name' => $c->country_name,
|
||||
'name' => $c->country_name ?: \App\Data\Countries::name($c->country),
|
||||
'count' => (int) $c->cnt,
|
||||
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
|
||||
])->values();
|
||||
@ -3722,7 +3722,7 @@ class VideoController extends Controller
|
||||
: asset('images/default-avatar.svg'))
|
||||
: 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,
|
||||
'created_at' => $s->created_at,
|
||||
];
|
||||
@ -3868,7 +3868,7 @@ class VideoController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'country' => $country,
|
||||
'country_name' => $countryName ?? $country,
|
||||
'country_name' => $countryName ?: (\App\Data\Countries::name($country) ?? $country),
|
||||
'total_views' => $totalViews,
|
||||
'registered_users' => $registeredUsers,
|
||||
'guest_count' => $guestCount,
|
||||
@ -3943,7 +3943,7 @@ class VideoController extends Controller
|
||||
->orderByDesc('cnt')
|
||||
->limit(5)
|
||||
->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([
|
||||
'date' => $day->format('M d, Y'),
|
||||
@ -3971,7 +3971,7 @@ class VideoController extends Controller
|
||||
->map(fn ($r) => [
|
||||
'type' => $r->type,
|
||||
'country' => $r->country,
|
||||
'country_name' => $r->country_name,
|
||||
'country_name' => $r->country_name ?: \App\Data\Countries::name($r->country),
|
||||
'at' => $r->downloaded_at,
|
||||
]);
|
||||
|
||||
@ -4101,7 +4101,7 @@ class VideoController extends Controller
|
||||
foreach ($accesses as $a) {
|
||||
$code = $a->country ?: 'XX';
|
||||
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']++;
|
||||
|
||||
@ -4199,7 +4199,7 @@ class VideoController extends Controller
|
||||
foreach ($rows as $r) {
|
||||
$code = $r->country ?: 'XX';
|
||||
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']++;
|
||||
}
|
||||
|
||||
391
drafts/VS Screen Arena - Standalone(1).html
Normal file
391
drafts/VS Screen Arena - Standalone(1).html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
391
drafts/VS-ScreenArena-Standalone.html
Normal file
391
drafts/VS-ScreenArena-Standalone.html
Normal file
File diff suppressed because one or more lines are too long
@ -479,10 +479,10 @@
|
||||
@else
|
||||
@php $maxViews = $viewsByCountry->first()->total; @endphp
|
||||
@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-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</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 ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}</div>
|
||||
<div class="country-bar-wrap">
|
||||
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
||||
</div>
|
||||
@ -742,7 +742,11 @@ new Chart(document.getElementById('typeChart'), {
|
||||
// ── Country Chart ───────────────────────────────────────────────
|
||||
@if($viewsByCountry->isNotEmpty())
|
||||
(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 values = countryData.map(r => r.total);
|
||||
const maxVal = Math.max(...values);
|
||||
@ -1015,7 +1019,10 @@ if (typeChartInst) {
|
||||
// Country bar chart click
|
||||
const countryChartInst = Chart.getChart('countryChart');
|
||||
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) {
|
||||
if (!elements.length) return;
|
||||
const row = countryData[elements[0].index];
|
||||
|
||||
@ -294,7 +294,7 @@ function flagEmoji(string $code): string {
|
||||
<div class="country-row">
|
||||
<span class="country-rank">{{ $i + 1 }}</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" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
||||
</div>
|
||||
@ -423,7 +423,7 @@ function flagEmoji(string $code): string {
|
||||
<td>
|
||||
@if($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
|
||||
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
||||
@endif
|
||||
@ -498,7 +498,7 @@ new Chart(document.getElementById('dailyChart'), {
|
||||
|
||||
// ── Country chart ───────────────────────────────────────────────────────────
|
||||
@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 countryMax = Math.max(...countryData);
|
||||
|
||||
|
||||
377
resources/views/components/partials/scorebar-mini.blade.php
Normal file
377
resources/views/components/partials/scorebar-mini.blade.php
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||||
536
resources/views/components/partials/vs-mini.blade.php
Normal file
536
resources/views/components/partials/vs-mini.blade.php
Normal 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
|
||||
@ -54,11 +54,21 @@ $sizeClasses = match($size) {
|
||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||
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')">
|
||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||
@include('components.partials.vs-mini', ['video' => $video])
|
||||
@endif
|
||||
@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' }}">
|
||||
</video>
|
||||
@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 --}}
|
||||
<div class="audio-preview-overlay">
|
||||
<div class="audio-eq">
|
||||
|
||||
@ -879,8 +879,12 @@
|
||||
clearErrors();
|
||||
lockButtons(true);
|
||||
|
||||
// Edit mode → just update the record. Create mode → upload the video first.
|
||||
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst();
|
||||
// Skip the video-upload step when:
|
||||
// - 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
|
||||
.then(() => postMatch(intent))
|
||||
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
|
||||
|
||||
559
resources/views/videos/partials/match/vs/index.blade.php
Normal file
559
resources/views/videos/partials/match/vs/index.blade.php
Normal 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
|
||||
@ -2230,8 +2230,10 @@
|
||||
<span class="replay-badge-word">REPLAY</span>
|
||||
<span class="replay-badge-speed" id="replayBadgeSpeed">×1</span>
|
||||
</div>
|
||||
{{-- Karate/Taekwondo scoreboard overlay + VS intro + gear-menu toggles --}}
|
||||
{{-- Karate/Taekwondo scoreboard overlay + gear-menu toggles --}}
|
||||
@include('videos.partials.match.scoreboard.index')
|
||||
{{-- VS intro card — first-play arena screen (skippable) --}}
|
||||
@include('videos.partials.match.vs.index')
|
||||
</x-slot:overlay>
|
||||
</x-video-player>
|
||||
|
||||
|
||||
38
resources/views/videos/vs-preview.blade.php
Normal file
38
resources/views/videos/vs-preview.blade.php
Normal 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>
|
||||
@ -40,6 +40,10 @@ Route::get('/videos/{numericId}', function ($numericId) {
|
||||
|
||||
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}/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}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track');
|
||||
Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user