Compare commits

..

No commits in common. "master" and "professional-match-overly" have entirely different histories.

40 changed files with 849 additions and 6190 deletions

View File

@ -1,47 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\VideoController;
use App\Models\Video;
use Illuminate\Console\Command;
/**
* Renders the 1200×630 VS composition PNG for a match video's social-share
* preview. Runs the same helper used by VideoController@ogImage but from a
* CLI process context, because Chromium reliably fails to launch under
* PHP-FPM ("Failed to launch the browser process: Code: null") on this
* host. Invoke detached via `nohup` from either the ogImage endpoint (on
* cache miss) or SportsMatchController on save.
*/
class RenderMatchOgImage extends Command
{
protected $signature = 'og:render-match {video}';
protected $description = 'Render the OG social-share PNG for a match video';
public function handle(): int
{
$video = Video::find($this->argument('video'));
if (! $video) {
$this->error("Video {$this->argument('video')} not found.");
return 1;
}
if ($video->type !== 'match' || ! $video->sportsMatch) {
$this->error("Video {$video->id} is not a match with a sports record.");
return 1;
}
$controller = new VideoController();
$r = new \ReflectionMethod($controller, 'renderMatchOgImage');
$r->setAccessible(true);
$png = $r->invoke($controller, $video);
if ($png === null) {
$this->error("Render failed. See laravel.log.");
return 1;
}
$this->info("Rendered " . strlen($png) . " bytes for video {$video->id}.");
return 0;
}
}

View File

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

View File

@ -2,7 +2,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Controllers\VideoController;
use App\Models\SportsMatch; use App\Models\SportsMatch;
use App\Services\NasSyncService; use App\Services\NasSyncService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -35,23 +34,6 @@ class SportsMatchController extends Controller
$this->handleImages($match, $request, $nas); $this->handleImages($match, $request, $nas);
$match->save(); $match->save();
if ($match->video && $match->video->title !== $match->title) {
$match->video->title = $match->title;
$match->video->save();
}
// Prefetch the OG social-share PNG so WhatsApp/Facebook crawlers
// get the composed VS card on first share, not the fallback frame.
if ($match->video) {
// Blocks the response by ~35s while the VS PNG renders. That
// wait is intentional: the user typically shares the URL right
// after saving, and WhatsApp/Facebook cache whatever they see
// on first crawl. If the render hasn't finished when the
// crawler hits, we'd be stuck on the thumbnail fallback in
// every future share.
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
}
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'message' => 'Match saved as draft.', 'message' => 'Match saved as draft.',
@ -69,21 +51,6 @@ class SportsMatchController extends Controller
$this->handleImages($sportsMatch, $request, $nas); $this->handleImages($sportsMatch, $request, $nas);
$sportsMatch->save(); $sportsMatch->save();
// Keep the parent Video's display title in sync with the match title
// so the match page header, share metadata, and search results all
// reflect the edited value.
if ($sportsMatch->video && $sportsMatch->video->title !== $sportsMatch->title) {
$sportsMatch->video->title = $sportsMatch->title;
$sportsMatch->video->save();
}
if ($sportsMatch->video) {
// Synchronous re-render on edit: guarantees the fresh VS card
// exists at the new versioned URL before the JSON response
// returns and the user can share the link. See renderMatchOgSync.
(new VideoController())->renderMatchOgSync($sportsMatch->video->fresh(['sportsMatch']));
}
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'message' => 'Match updated.', 'message' => 'Match updated.',

View File

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

View File

@ -26,7 +26,7 @@ class VideoController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']); $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
} }
public function index() public function index()
@ -3126,65 +3126,12 @@ class VideoController extends Controller
$dest .= '?track=' . (int) $request->input('track'); $dest .= '?track=' . (int) $request->input('track');
} }
// Social scrapers (WhatsApp, Facebook, Twitter, LinkedIn, Telegram,
// Discord, Slack) frequently DO NOT follow 302 redirects when
// building link previews — they read og:* tags from the response
// body of the URL they were given. Serving them a redirect leaves
// them with no title/image, which is what the user hit today. For
// those user agents, render the target video page directly so the
// OG tags (title, description, VS card image) are in the response.
// Real users still get the 302 so cookies + tracking behave the same.
$ua = (string) $request->userAgent();
$isScraper = (bool) preg_match(
'#(whatsapp|facebookexternalhit|facebot|twitterbot|linkedinbot|slackbot|telegrambot|discordbot|skypeuripreview|pinterest|redditbot|embedly|vkshare|w3c_validator|bingbot|googlebot|applebot|yandex|duckduckbot|iframely|nuzzel|quora link preview|outbrain)#i',
$ua
);
if ($isScraper) {
return $this->show($request, $video);
}
return redirect($dest) return redirect($dest)
->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5)); ->withCookie(cookie('_did', $did, 60 * 24 * 365 * 5));
} }
/**
* Standalone HTML page that renders the sports-match VS composition
* at 1200×630. Called only by Browsershot from ogImage() below to
* generate the social-share preview PNG for match videos.
*/
public function vsPreview(Video $video)
{
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
// Force http scheme for URLs generated on this page (asset(), route(),
// media thumbnail routes) — the ogImage renderer proxies Chromium at
// this route via loopback (http://127.0.0.1:80), so the app's global
// URL::forceScheme('https') would produce URLs Chromium can't reach.
\Illuminate\Support\Facades\URL::forceScheme('http');
return response()
->view('videos.partials.match.vs-og', ['video' => $video])
->header('X-Robots-Tag', 'noindex, nofollow');
}
public function ogImage(Video $video) public function ogImage(Video $video)
{ {
// Sports matches get a rendered VS card — the actual render is done
// by a CLI artisan command (Chromium can't launch under PHP-FPM
// reliably), fired detached on cache miss + prefetched on match
// save. If the cached PNG exists, serve it. Otherwise return the
// thumbnail fallback below and let the background job produce the
// PNG for future requests.
if ($video->type === 'match' && $video->sportsMatch) {
$cached = $this->cachedMatchOgPath($video);
if (is_file($cached)) {
return response()->file($cached, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'public, max-age=86400',
]);
}
$this->dispatchMatchOgRender($video);
// Fall through to thumbnail path while the render runs.
}
// If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews // If video has a thumbnail, convert + resize to a small JPEG for WhatsApp/social previews
if ($video->thumbnail) { if ($video->thumbnail) {
$path = $video->localThumbnailPath(); $path = $video->localThumbnailPath();
@ -3436,7 +3383,7 @@ class VideoController extends Controller
$totalGeo = $rawCountries->sum('cnt'); $totalGeo = $rawCountries->sum('cnt');
$countries = $rawCountries->map(fn ($c) => [ $countries = $rawCountries->map(fn ($c) => [
'code' => $c->country, 'code' => $c->country,
'name' => $c->country_name ?: \App\Data\Countries::name($c->country), 'name' => $c->country_name,
'count' => (int) $c->cnt, 'count' => (int) $c->cnt,
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0, 'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
])->values(); ])->values();
@ -3775,7 +3722,7 @@ class VideoController extends Controller
: asset('images/default-avatar.svg')) : asset('images/default-avatar.svg'))
: null, : null,
'country' => $topCountry ? $topCountry->country : null, 'country' => $topCountry ? $topCountry->country : null,
'country_name' => $topCountry ? ($topCountry->country_name ?: \App\Data\Countries::name($topCountry->country)) : null, 'country_name' => $topCountry ? $topCountry->country_name : null,
'reach' => $accesses, 'reach' => $accesses,
'created_at' => $s->created_at, 'created_at' => $s->created_at,
]; ];
@ -3921,7 +3868,7 @@ class VideoController extends Controller
return response()->json([ return response()->json([
'country' => $country, 'country' => $country,
'country_name' => $countryName ?: (\App\Data\Countries::name($country) ?? $country), 'country_name' => $countryName ?? $country,
'total_views' => $totalViews, 'total_views' => $totalViews,
'registered_users' => $registeredUsers, 'registered_users' => $registeredUsers,
'guest_count' => $guestCount, 'guest_count' => $guestCount,
@ -3996,7 +3943,7 @@ class VideoController extends Controller
->orderByDesc('cnt') ->orderByDesc('cnt')
->limit(5) ->limit(5)
->get() ->get()
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name ?: \App\Data\Countries::name($c->country), 'count' => (int) $c->cnt]); ->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);
return response()->json([ return response()->json([
'date' => $day->format('M d, Y'), 'date' => $day->format('M d, Y'),
@ -4024,7 +3971,7 @@ class VideoController extends Controller
->map(fn ($r) => [ ->map(fn ($r) => [
'type' => $r->type, 'type' => $r->type,
'country' => $r->country, 'country' => $r->country,
'country_name' => $r->country_name ?: \App\Data\Countries::name($r->country), 'country_name' => $r->country_name,
'at' => $r->downloaded_at, 'at' => $r->downloaded_at,
]); ]);
@ -4154,7 +4101,7 @@ class VideoController extends Controller
foreach ($accesses as $a) { foreach ($accesses as $a) {
$code = $a->country ?: 'XX'; $code = $a->country ?: 'XX';
if (! isset($countries[$code])) { if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0]; $countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
} }
$countries[$code]['count']++; $countries[$code]['count']++;
@ -4252,7 +4199,7 @@ class VideoController extends Controller
foreach ($rows as $r) { foreach ($rows as $r) {
$code = $r->country ?: 'XX'; $code = $r->country ?: 'XX';
if (! isset($countries[$code])) { if (! isset($countries[$code])) {
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0]; $countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
} }
$countries[$code]['count']++; $countries[$code]['count']++;
} }
@ -4294,231 +4241,5 @@ class VideoController extends Controller
'recent' => $recent, 'recent' => $recent,
]); ]);
} }
/**
* Absolute path of the cache file for this match's OG PNG. The stamp
* embedded in the filename is sportsMatch.updated_at so a match edit
* automatically produces a fresh URL and stale variants get cleaned
* during the next render.
*/
public function cachedMatchOgPath(Video $video): string
{
$stamp = optional($video->sportsMatch->updated_at ?? $video->updated_at)->timestamp
?? $video->id;
// JPEG (not PNG) — WhatsApp silently rejects OG images above ~300 KB
// and the composed PNG lands at ~700 KB. A quality-85 JPEG of the
// same frame is 100-180 KB and looks identical for a share preview.
return storage_path("app/og-cache/match-{$video->id}-{$stamp}.jpg");
}
/**
* Render the OG PNG synchronously by spawning the artisan CLI and
* waiting for it to finish (~35s). Called from SportsMatchController
* on save so the file is guaranteed to exist before the user copies
* the share URL critical because WhatsApp/Facebook crawl the URL
* once and cache whatever they get, so if the crawler beats the
* render we end up permanently stuck on the fallback thumbnail.
*
* Chrome won't launch under PHP-FPM's process context (missing HOME,
* stripped env). The artisan CLI subprocess is invoked with a full
* env explicitly restored, which sidesteps the FPM restriction.
*/
public function renderMatchOgSync(Video $video, int $timeoutSeconds = 45): bool
{
// Skip if the PNG for this exact stamp already exists.
$cacheFile = $this->cachedMatchOgPath($video);
if (is_file($cacheFile) && filesize($cacheFile) > 0) {
return true;
}
$php = '/usr/bin/php';
$art = base_path('artisan');
$cmd = sprintf(
'/usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d 2>&1',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id
);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$env = ['HOME' => '/tmp', 'TMPDIR' => '/tmp', 'PATH' => '/usr/bin:/bin'];
$proc = @proc_open($cmd, $descriptors, $pipes, '/tmp', $env);
if (! is_resource($proc)) {
\Log::warning('renderMatchOgSync: proc_open failed', ['video' => $video->id]);
return false;
}
stream_set_blocking($pipes[1], false);
$deadline = microtime(true) + $timeoutSeconds;
while (microtime(true) < $deadline) {
$status = proc_get_status($proc);
if (! $status['running']) break;
usleep(200000);
}
$out = @stream_get_contents($pipes[1]);
@fclose($pipes[1]);
@fclose($pipes[2]);
$exit = proc_close($proc);
if (! is_file($cacheFile)) {
\Log::warning('renderMatchOgSync: render finished without a PNG', [
'video' => $video->id,
'exit' => $exit,
'output' => $out,
]);
return false;
}
return true;
}
/**
* Fire the artisan renderer in a detached background process so this
* request can respond immediately with the fallback thumbnail. Used
* as a last-resort backfill when ogImage is hit and no PNG exists
* (e.g. matches that pre-date this feature).
*/
public function dispatchMatchOgRender(Video $video): void
{
// Skip if a render was fired for this stamp very recently — the
// marker file avoids stampeding multiple detached processes for
// repeated hits before the first one finishes.
$marker = $this->cachedMatchOgPath($video) . '.rendering';
if (is_file($marker) && (time() - filemtime($marker)) < 60) {
return;
}
@mkdir(dirname($marker), 0775, true);
@touch($marker);
// PHP_BINARY under FPM is /usr/sbin/php-fpm, which won't run
// artisan — must use the CLI binary explicitly.
$php = '/usr/bin/php';
$art = base_path('artisan');
$log = storage_path('logs/og-render.log');
// `setsid` detaches the child into a new session so it survives
// FPM's request teardown. `< /dev/null` closes stdin so bash
// treats it as fully backgrounded. `env HOME=/tmp` is mandatory:
// FPM clears the environment (clear_env = yes), the artisan
// command inherits that empty env, and Puppeteer/Chromium then
// fail with "Failed to launch the browser process: Code: null"
// because they can't resolve a user-data directory without HOME.
$cmd = sprintf(
'setsid /usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d >> %s 2>&1 < /dev/null &',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id,
escapeshellarg($log)
);
@exec($cmd);
}
/**
* Render (or return the cached) 1200×630 PNG of the sports-match VS
* composition. Cached under data/app/og-cache/match-{id}-{stamp}.png
* so a match edit (which bumps updated_at) automatically produces a
* fresh URL for social recrawlers.
*/
public function renderMatchOgImage(Video $video): ?string
{
$cacheFile = $this->cachedMatchOgPath($video);
$cacheDir = dirname($cacheFile);
if (is_file($cacheFile)) {
return @file_get_contents($cacheFile) ?: null;
}
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
// Purge older stamped variants for this match so the cache dir
// doesn't grow unbounded across edits. Includes the legacy .png
// extension in case any old files are still around.
foreach (glob($cacheDir . "/match-{$video->id}-*.{jpg,png}", GLOB_BRACE) ?: [] as $old) {
if ($old !== $cacheFile) @unlink($old);
}
@unlink($cacheFile . '.rendering');
try {
// Rebuild the URL as plain http on the app's own hostname so
// Chromium can reach it via loopback. --host-resolver-rules
// below points that hostname at 127.0.0.1:80 (nginx). This
// sidesteps Cloudflare (origin isn't reachable from itself
// over its public hostname) without needing a local TLS cert.
// Hit nginx over loopback. server_name is `_` (catch-all) so
// Host header doesn't matter. Chromium can't reach the origin
// over its public hostname (Cloudflare-fronted) and mapping
// the hostname via --host-resolver-rules trips ERR_BLOCKED_BY_CLIENT
// on public TLDs. vsPreview() forces http on generated URLs
// so all subresources (flag CSS, media thumbnails, headshots)
// also resolve to this loopback origin.
$url = 'http://127.0.0.1' . route('videos.vsOgFrame', $video, false);
$chrome = base_path('data/puppeteer-cache/chrome');
$binary = null;
if (is_dir($chrome)) {
$matches = glob($chrome . '/linux-*/chrome-linux64/chrome');
if ($matches) { $binary = $matches[0]; }
}
// PHP-FPM runs without a HOME env var; Chromium's user-data-dir
// resolution fails without one and the browser exits before
// Puppeteer can attach. /tmp is writable by www-data and gets
// cleaned periodically, which is fine for a screenshot job.
$shot = \Spatie\Browsershot\Browsershot::url($url)
->windowSize(1200, 675)
->deviceScaleFactor(1)
->waitUntilNetworkIdle()
// Output JPEG @ q85 (~150 KB) instead of PNG (~700 KB).
// WhatsApp silently drops OG images above roughly 300 KB.
->setScreenshotType('jpeg', 85)
->setNpmBinary('/usr/bin/npm')
// PHP-FPM starts with a stripped env (clear_env = yes) so
// Chromium sees no HOME/TMPDIR/PATH. Without HOME the
// user-data-dir can't be created; without TMPDIR crashpad's
// shmem sockets can't bind; without PATH Chromium can't
// fork its helper processes. Pass all three explicitly.
->setEnvironmentOptions([
'HOME' => '/tmp',
'TMPDIR' => '/tmp',
'PATH' => '/usr/bin:/bin',
])
// PHP-FPM defaults rlimit_files to 1024; Chromium opens
// way more than that on startup and dies silently
// ("Failed to launch the browser process: Code: null").
// The wrapper script re-execs node with a raised limit
// via prlimit, so we don't have to restart php-fpm to
// bump the pool-wide rlimit_files setting.
->setNodeBinary(base_path('bin/browsershot-node.sh'))
->noSandbox()
->addChromiumArguments([
'disable-gpu',
'hide-scrollbars',
'disable-dev-shm-usage',
// FPM's process context confuses Chromium's zygote —
// crashpad fails to fork with its args and chrome
// exits before puppeteer can attach. Isolating the
// user-data-dir per request + turning off crash
// reporting sidesteps the whole handler chain.
'user-data-dir=/tmp/chrome-og-' . getmypid() . '-' . mt_rand(),
'disable-crash-reporter',
'no-crash-upload',
]);
if ($binary) {
$shot->setChromePath($binary);
}
$shot->save($cacheFile);
} catch (\Throwable $e) {
\Log::warning('vs og render failed', [
'video' => $video->id,
'error' => $e->getMessage(),
]);
return null;
}
return is_file($cacheFile) ? (@file_get_contents($cacheFile) ?: null) : null;
}
} }

View File

@ -68,14 +68,7 @@ class SportsMatch extends Model
], ],
'weight_category' => $trim($p['weight_class'] ?? null), 'weight_category' => $trim($p['weight_class'] ?? null),
'division' => $trim($c['division'] ?? null), 'division' => $trim($c['division'] ?? null),
// Sub-header "stage" shown between the two yellow lines above 'championship' => $trim($c['championship_name'] ?? null) ?? $trim($this->event_name),
// the weight class (e.g. "Finals", "SEMI FINAL"). Sourced from
// the SportsMatch->title column — the form's "Match title" input.
'stage' => $trim($this->title),
// "Event title" = the championship / tournament name shown above the
// fighters. Prefer the SportsMatch->event_name column (the field the
// uploader labels "Event title"), fall back to competition.championship_name.
'championship' => $trim($this->event_name) ?? $trim($c['championship_name'] ?? null),
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null), 'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
'court' => $trim($c['court'] ?? null), 'court' => $trim($c['court'] ?? null),
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", … 'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …

View File

@ -1,7 +0,0 @@
#!/bin/sh
# Wraps `node` with a raised file-descriptor limit. PHP-FPM starts with
# rlimit_files=1024; Chromium opens ~2000 fds on startup and dies before
# Puppeteer can attach, surfacing as "Failed to launch the browser
# process: Code: null". Bumping just this subprocess tree avoids
# restarting FPM to raise the pool-wide limit.
exec /usr/bin/prlimit --nofile=65536 /usr/bin/node "$@"

View File

@ -14,8 +14,7 @@
"laravel/tinker": "^2.8", "laravel/tinker": "^2.8",
"p7h/nas-file-manager": "dev-main", "p7h/nas-file-manager": "dev-main",
"php-ffmpeg/php-ffmpeg": "^1.4", "php-ffmpeg/php-ffmpeg": "^1.4",
"pragmarx/google2fa-laravel": "^3.0", "pragmarx/google2fa-laravel": "^3.0"
"spatie/browsershot": "^3.61"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.9.1", "fakerphp/faker": "^1.9.1",

341
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "bdb64417cd2491642a0712fe40bade22", "content-hash": "40fa327b55e9b6fafab4b2da3f763724",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1457,90 +1457,6 @@
], ],
"time": "2025-08-22T14:27:06+00:00" "time": "2025-08-22T14:27:06+00:00"
}, },
{
"name": "intervention/image",
"version": "2.7.2",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "04be355f8d6734c826045d02a1079ad658322dad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad",
"reference": "04be355f8d6734c826045d02a1079ad658322dad",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"guzzlehttp/psr7": "~1.1 || ^2.0",
"php": ">=5.4.0"
},
"require-dev": {
"mockery/mockery": "~0.9.2",
"phpunit/phpunit": "^4.8 || ^5.7 || ^7.5.15"
},
"suggest": {
"ext-gd": "to use GD library based image processing.",
"ext-imagick": "to use Imagick based image processing.",
"intervention/imagecache": "Caching extension for the Intervention Image library"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Image": "Intervention\\Image\\Facades\\Image"
},
"providers": [
"Intervention\\Image\\ImageServiceProvider"
]
},
"branch-alias": {
"dev-master": "2.4-dev"
}
},
"autoload": {
"psr-4": {
"Intervention\\Image\\": "src/Intervention/Image"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io/"
}
],
"description": "Image handling and manipulation library with support for Laravel integration",
"homepage": "http://image.intervention.io/",
"keywords": [
"gd",
"image",
"imagick",
"laravel",
"thumbnail",
"watermark"
],
"support": {
"issues": "https://github.com/Intervention/image/issues",
"source": "https://github.com/Intervention/image/tree/2.7.2"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
}
],
"time": "2022-05-21T17:30:32+00:00"
},
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "10.50.2", "version": "10.50.2",
@ -2321,71 +2237,6 @@
}, },
"time": "2026-01-23T15:30:45+00:00" "time": "2026-01-23T15:30:45+00:00"
}, },
{
"name": "league/glide",
"version": "2.3.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/glide.git",
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/glide/zipball/b8e946dd87c79a9dce3290707ab90b5b52602813",
"reference": "b8e946dd87c79a9dce3290707ab90b5b52602813",
"shasum": ""
},
"require": {
"intervention/image": "^2.7",
"league/flysystem": "^2.0|^3.0",
"php": "^7.2|^8.0",
"psr/http-message": "^1.0|^2.0"
},
"require-dev": {
"mockery/mockery": "^1.3.3",
"phpunit/php-token-stream": "^3.1|^4.0",
"phpunit/phpunit": "^8.5|^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Glide\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Reinink",
"email": "jonathan@reinink.ca",
"homepage": "http://reinink.ca"
},
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com",
"homepage": "https://titouangalopin.com"
}
],
"description": "Wonderfully easy on-demand image manipulation library with an HTTP based API.",
"homepage": "http://glide.thephpleague.com",
"keywords": [
"ImageMagick",
"editing",
"gd",
"image",
"imagick",
"league",
"manipulation",
"processing"
],
"support": {
"issues": "https://github.com/thephpleague/glide/issues",
"source": "https://github.com/thephpleague/glide/tree/2.3.2"
},
"time": "2025-03-21T13:48:39+00:00"
},
{ {
"name": "league/mime-type-detection", "name": "league/mime-type-detection",
"version": "1.16.0", "version": "1.16.0",
@ -4170,196 +4021,6 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "time": "2025-12-14T04:43:48+00:00"
}, },
{
"name": "spatie/browsershot",
"version": "3.61.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/browsershot.git",
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/browsershot/zipball/14d75679390b8b84a71b3a17dc5905928deeb887",
"reference": "14d75679390b8b84a71b3a17dc5905928deeb887",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^8.0",
"spatie/image": "^1.5.3|^2.0",
"spatie/temporary-directory": "^1.1|^2.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0"
},
"require-dev": {
"pestphp/pest": "^1.20",
"spatie/phpunit-snapshot-assertions": "^4.2.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Browsershot\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://github.com/freekmurze",
"role": "Developer"
}
],
"description": "Convert a webpage to an image or pdf using headless Chrome",
"homepage": "https://github.com/spatie/browsershot",
"keywords": [
"chrome",
"convert",
"headless",
"image",
"pdf",
"puppeteer",
"screenshot",
"webpage"
],
"support": {
"source": "https://github.com/spatie/browsershot/tree/3.61.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2023-12-21T10:00:28+00:00"
},
{
"name": "spatie/image",
"version": "2.2.7",
"source": {
"type": "git",
"url": "https://github.com/spatie/image.git",
"reference": "2f802853aab017aa615224daae1588054b5ab20e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image/zipball/2f802853aab017aa615224daae1588054b5ab20e",
"reference": "2f802853aab017aa615224daae1588054b5ab20e",
"shasum": ""
},
"require": {
"ext-exif": "*",
"ext-json": "*",
"ext-mbstring": "*",
"league/glide": "^2.2.2",
"php": "^8.0",
"spatie/image-optimizer": "^1.7",
"spatie/temporary-directory": "^1.0|^2.0",
"symfony/process": "^3.0|^4.0|^5.0|^6.0"
},
"require-dev": {
"pestphp/pest": "^1.22",
"phpunit/phpunit": "^9.5",
"symfony/var-dumper": "^4.0|^5.0|^6.0",
"vimeo/psalm": "^4.6"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Manipulate images with an expressive API",
"homepage": "https://github.com/spatie/image",
"keywords": [
"image",
"spatie"
],
"support": {
"source": "https://github.com/spatie/image/tree/2.2.7"
},
"funding": [
{
"url": "https://spatie.be/open-source/support-us",
"type": "custom"
},
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2023-07-24T13:54:13+00:00"
},
{
"name": "spatie/image-optimizer",
"version": "1.10.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/image-optimizer.git",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/image-optimizer/zipball/333c03952289dc2df0a91874636a0dffeb5b6aec",
"reference": "333c03952289dc2df0a91874636a0dffeb5b6aec",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"php": "^7.4|^8.0",
"psr/log": "^1.0 | ^2.0 | ^3.0",
"symfony/process": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^1.21|^2.0|^3.0|^4.0",
"phpunit/phpunit": "^8.5.21|^9.4.4|^10.0|^11.0|^12.0",
"symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\ImageOptimizer\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily optimize images using PHP",
"homepage": "https://github.com/spatie/image-optimizer",
"keywords": [
"image-optimizer",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/image-optimizer/issues",
"source": "https://github.com/spatie/image-optimizer/tree/1.10.0"
},
"time": "2026-06-29T08:28:30+00:00"
},
{ {
"name": "spatie/temporary-directory", "name": "spatie/temporary-directory",
"version": "2.3.1", "version": "2.3.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,349 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<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>
body { margin:0; background:#0a0a0c; }
a { color:#e8534a; } a:hover { color:#ff7d70; }
@keyframes riseIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:none; } }
@keyframes driftA { 0%,100% { transform: translate3d(0,0,0) scale(1); } 50% { transform: translate3d(60px,-28px,0) scale(1.14); } }
@keyframes driftB { 0%,100% { transform: translate3d(0,0,0) scale(1.08); } 50% { transform: translate3d(-54px,26px,0) scale(1); } }
@keyframes sweep { 0% { transform: translateX(-60%) skewX(-14deg); opacity:0; } 25% { opacity:.5; } 60% { opacity:0; } 100% { transform: translateX(160%) skewX(-14deg); opacity:0; } }
@keyframes pulseDot { 0%,100% { opacity:1; } 50% { opacity:.25; } }
</style>
</helmet>
<div style="min-height:100vh;background:#0a0a0c;padding:40px;display:flex;flex-direction:column;align-items:flex-start;gap:22px;overflow-x:auto;font-family:'Barlow Condensed',sans-serif">
<div style="position:relative;width:1150px;flex:none;aspect-ratio:16/9;overflow:hidden;background:#08080a;box-shadow:0 30px 80px rgba(0,0,0,.6)">
<div style="position:absolute;inset:-12%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);width:70%;left:-6%;animation:driftA 19s ease-in-out infinite"></div>
<div style="position:absolute;inset:-12%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);width:70%;left:36%;animation:driftB 23s ease-in-out infinite"></div>
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:sweep 11s linear infinite"></div>
<div style="position:absolute;inset:0;opacity:.12;mix-blend-mode:overlay;background:repeating-linear-gradient(115deg, rgba(255,255,255,.6) 0 1px, transparent 1px 5px)"></div>
<div style="position:absolute;top:0;bottom:0;left:0;width:54%;clip-path:polygon(0 0, 100% 0, 78% 100%, 0 100%);background:linear-gradient(120deg, rgba(122,26,22,.5), rgba(8,8,10,0) 76%)"></div>
<div style="position:absolute;top:0;bottom:0;right:0;width:54%;clip-path:polygon(22% 0, 100% 0, 100% 100%, 0 100%);background:linear-gradient(300deg, rgba(24,52,110,.5), rgba(8,8,10,0) 76%)"></div>
<div style="position:absolute;top:34px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-family:'Zen Old Mincho',serif;font-size:24px;letter-spacing:.44em;color:#efe9e0;text-transform:uppercase;text-indent:.44em">Takeone Karate Series</div>
<div style="display:flex;align-items:center;gap:14px;font-size:12px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">
<span>Ladies</span><span style="width:4px;height:4px;background:#e8534a"></span><span>Classification</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>Round 1</span>
</div>
</div>
<div style="position:absolute;inset:96px 0 54px;display:grid;grid-template-columns:1fr 200px 1fr;align-items:start">
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
<div style="position:relative;width:228px;height:304px">
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div style="position:absolute;bottom:-26px;right:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
</div>
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Fawzia Ahmed</div>
<div style="display:flex;align-items:center;gap:12px">
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
<span style="font-size:14px;letter-spacing:.3em;color:#d3b3ad;text-transform:uppercase">Bahrain</span>
</div>
<div style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">Manama Karate Club</div>
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding-top:96px">
<span style="font-family:'Zen Old Mincho',serif;font-size:88px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 34px rgba(0,0,0,.75)">VS</span>
<span style="padding:7px 16px;border:1px solid rgba(255,255,255,.3);font-size:14px;letter-spacing:.32em;color:#efe9e0;text-transform:uppercase">61 kg</span>
<span style="font-size:11px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">Ladies · 3 min</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
<div style="position:relative;width:228px;height:304px">
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div style="position:absolute;bottom:-26px;left:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
</div>
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Noor Salman</div>
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:14px;letter-spacing:.3em;color:#a8b6cf;text-transform:uppercase">Bahrain</span>
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
</div>
<div style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">Riffa Martial Arts</div>
</div>
</div>
</div>
<div style="position:absolute;bottom:26px;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:16px;font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">
<span>Bout 12</span><span style="width:4px;height:4px;background:#e8534a"></span><span>Tatami 1</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>Aug 08, 2026 · Manama</span>
</div>
</div>
<div style="position:relative;width:1150px;flex:none;aspect-ratio:16/9;overflow:hidden;border-radius:10px;background:#111;box-shadow:0 30px 80px rgba(0,0,0,.6)">
<img src="assets/mat.png" alt="" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">
<div style="position:absolute;inset:0;background:linear-gradient(to top, rgba(6,6,8,.9) 0%, rgba(6,6,8,.45) 15%, rgba(6,6,8,0) 30%), linear-gradient(to bottom, rgba(6,6,8,.85) 0%, rgba(6,6,8,.4) 14%, rgba(6,6,8,0) 30%)"></div>
<div style="display:{{ overlayDisplay }}">
<!-- top left: match identity -->
<div style="position:absolute;top:22px;left:26px;pointer-events:none;display:{{ dIdentity }};align-items:stretch;gap:12px">
<div style="width:4px;background:linear-gradient(#e8534a,#7c1d18)"></div>
<div style="display:flex;flex-direction:column;gap:2px">
<div style="font-family:'Zen Old Mincho',serif;font-size:15px;letter-spacing:.34em;color:#efe9e0;text-transform:uppercase">{{ disciplineLabel }}</div>
<div style="font-size:13px;letter-spacing:.28em;color:#cfc9c1;text-transform:uppercase;text-shadow:0 1px 6px rgba(0,0,0,.9)">Ladies Classification · 61 kg · Round 1</div>
</div>
</div>
<!-- top right: live point ticker -->
<div style="position:absolute;top:20px;right:24px;width:262px;pointer-events:none;display:{{ dTicker }};flex-direction:column;gap:7px">
<div style="display:flex;align-items:center;justify-content:flex-end;gap:8px;font-size:12px;letter-spacing:.3em;color:#d5cfc7;text-transform:uppercase;text-shadow:0 1px 6px rgba(0,0,0,.9)">
<span style="width:7px;height:7px;border-radius:50%;background:#e8534a;animation:pulseDot 1.6s infinite"></span>Live scoring
</div>
<sc-for list="{{ feed }}" as="ev" hint-placeholder-count="4">
<div style="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:riseIn .45s ease both;border-color:{{ ev.color }}">
<span style="font-size:12px;letter-spacing:.16em;color:#79736c">{{ ev.time }}</span>
<span style="font-size:17px;letter-spacing:.12em;font-weight:700;color:#efe9e0;text-transform:uppercase">{{ ev.name }}</span>
<span style="font-family:'Zen Old Mincho',serif;font-size:19px;font-weight:700;color:{{ ev.color }}">{{ ev.pts }}</span>
</div>
</sc-for>
</div>
<!-- bottom scoreboard -->
<div style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:{{ dBar }};align-items:stretch;gap:0;height:84px;z-index:2;pointer-events:none">
<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);display:{{ dClubs }};align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</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">
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:{{ dFlags }};align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
<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">Fawzia A.</span>
</div>
<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">Manama Karate Club</span>
<span style="display:{{ dPenalty }};font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#ffdf6b;color:#3a2a00;font-weight:700;flex:none">SENSHU</span>
</div>
</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)">{{ redCorner }}</span><span 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)">{{ akaScore }}</span></div>
</div>
</div>
<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 style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">{{ roundLabel }}</div>
<div style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">{{ clock }}</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>
<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)">{{ blueCorner }}</span><span 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)">{{ aoScore }}</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">Noor S.</span>
<span style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:{{ dFlags }};align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
</div>
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span style="display:{{ dPenalty }};font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">{{ penaltyLabel }}</span>
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.1em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Riffa Martial Arts</span>
</div>
</div>
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:{{ dClubs }};align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
</div>
</div>
</div>
<!-- point timeline strip -->
<div style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:{{ dTimeline }};gap:2px;z-index:2;pointer-events:none">
<sc-for list="{{ timeline }}" as="seg" hint-placeholder-count="14">
<div style="flex:1;background:{{ seg.color }}"></div>
</sc-for>
</div>
</div>
<div style="position:absolute;inset:0;z-index:8;display:{{ introDisplay }};opacity:{{ introOpacity }};transition:opacity .55s ease;background:#08080a;overflow:hidden">
<div style="position:absolute;inset:-12%;width:70%;left:-6%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);animation:driftA 19s ease-in-out infinite"></div>
<div style="position:absolute;inset:-12%;width:70%;left:36%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);animation:driftB 23s ease-in-out infinite"></div>
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:sweep 11s linear infinite"></div>
<div style="position:absolute;top:26px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:5px">
<div style="font-family:'Zen Old Mincho',serif;font-size:19px;letter-spacing:.42em;color:#efe9e0;text-transform:uppercase;text-indent:.42em">Takeone Karate Series</div>
<div style="font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">Ladies · Classification · Round 1</div>
</div>
<div style="position:absolute;inset:82px 0 46px;display:grid;grid-template-columns:1fr 150px 1fr;align-items:center">
<div style="display:flex;flex-direction:column;align-items:center;padding:0 20px">
<div style="position:relative;width:168px;height:224px">
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:10px;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div style="position:absolute;bottom:-18px;right:-18px;width:70px;height:70px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.7);text-align:center;line-height:1.3">CLUB<br>LOGO</div>
</div>
<div style="margin-top:30px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:6px">
<div style="font-size:30px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Fawzia Ahmed</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="width:30px;height:20px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
<span style="font-size:12px;letter-spacing:.26em;color:#d3b3ad;text-transform:uppercase">Bahrain · Manama Karate Club</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:10px">
<span style="font-family:'Zen Old Mincho',serif;font-size:64px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 30px rgba(0,0,0,.75)">VS</span>
<span style="padding:5px 13px;border:1px solid rgba(255,255,255,.3);font-size:12px;letter-spacing:.3em;color:#efe9e0;text-transform:uppercase">61 kg</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;padding:0 20px">
<div style="position:relative;width:168px;height:224px">
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:10px;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div style="position:absolute;bottom:-18px;left:-18px;width:70px;height:70px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.7);text-align:center;line-height:1.3">CLUB<br>LOGO</div>
</div>
<div style="margin-top:30px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:6px">
<div style="font-size:30px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">Noor Salman</div>
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:12px;letter-spacing:.26em;color:#a8b6cf;text-transform:uppercase">Bahrain · Riffa Martial Arts</span>
<span style="width:30px;height:20px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
</div>
</div>
</div>
</div>
</div>
<div style="position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;background:linear-gradient(to top, rgba(6,6,8,.94) 40%, rgba(6,6,8,0));z-index:9">
<div style="height:4px;background:rgba(255,255,255,.28);position:relative">
<div style="position:absolute;left:0;top:0;bottom:0;width:23%;background:#e8534a"></div>
<div style="position:absolute;left:23%;top:-3px;width:10px;height:10px;border-radius:50%;background:#e8534a"></div>
</div>
<div style="height:44px;padding:0 16px;display:flex;align-items:center;gap:18px;color:#efe9e0">
<button onClick="{{ replayIntro }}" style="display:flex;gap:4px;padding:0;background:transparent;border:0;cursor:pointer;color:inherit" style-hover="opacity:.75"><span style="width:4px;height:15px;background:currentColor"></span><span style="width:4px;height:15px;background:currentColor"></span></button>
<span style="display:flex;align-items:center;gap:2px"><span style="width:6px;height:9px;background:currentColor"></span><span style="width:0;height:0;border-right:8px solid currentColor;border-top:8px solid transparent;border-bottom:8px solid transparent"></span><span style="width:5px;height:5px;border-right:2px solid currentColor;border-top:2px solid currentColor;transform:rotate(45deg);margin-left:2px"></span></span>
<span style="font-size:13px;letter-spacing:.1em;color:#cfc9c1;font-variant-numeric:tabular-nums">0:57 / 4:11</span>
<span style="flex:1"></span>
<button onClick="{{ toggleMenu }}" style="width:24px;height:24px;flex:none;display:flex;align-items:center;justify-content:center;background:transparent;border:0;cursor:pointer;color:{{ gearColor }}" style-hover="color:#fff">
<span style="width:15px;height:15px;border:2px solid currentColor;border-radius:50%;display:block;position:relative"><span style="position:absolute;inset:3px;border:2px solid currentColor;border-radius:50%"></span></span>
</button>
<span style="width:22px;height:16px;border:2px solid #e8534a;border-radius:3px;display:block"></span>
<span style="width:22px;height:16px;border:2px solid currentColor;border-radius:2px;display:flex;align-items:flex-end;justify-content:flex-end;padding:2px"><span style="width:9px;height:6px;background:currentColor"></span></span>
<span style="width:22px;height:14px;border:2px solid currentColor;border-radius:2px;display:block"></span>
<span style="width:18px;height:16px;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:4px">
<span style="border-left:2px solid currentColor;border-top:2px solid currentColor"></span><span style="border-right:2px solid currentColor;border-top:2px solid currentColor"></span>
<span style="border-left:2px solid currentColor;border-bottom:2px solid currentColor"></span><span style="border-right:2px solid currentColor;border-bottom:2px solid currentColor"></span>
</span>
</div>
</div>
<div style="position:absolute;right:118px;bottom:58px;width:236px;z-index:10;display:{{ menuDisplay }};flex-direction:column;background:rgba(10,10,12,.94);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.16);box-shadow:0 20px 50px rgba(0,0,0,.6)">
<div style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,.1);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Scoreboard</div>
<sc-for list="{{ menuItems }}" as="it" hint-placeholder-count="7">
<button onClick="{{ it.toggle }}" style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 14px;background:transparent;border:0;cursor:pointer;font-family:'Barlow Condensed',sans-serif;font-size:14px;letter-spacing:.14em;color:#efe9e0;text-transform:uppercase;text-align:left" style-hover="background:rgba(255,255,255,.07)">
<span>{{ it.label }}</span>
<span style="width:26px;height:14px;flex:none;background:{{ it.track }};position:relative"><span style="position:absolute;top:2px;left:{{ it.knob }};width:10px;height:10px;background:#efe9e0"></span></span>
</button>
</sc-for>
</div>
</div>
<div style="width:1150px;flex:none;display:flex;align-items:center;justify-content:space-between;color:#6f6a64;font-size:14px;letter-spacing:.22em;text-transform:uppercase">
<span>Overlay is a function of playback time · points come from the Points panel</span>
<span style="color:#efe9e0">{{ statusLine }}</span>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;$preview&quot;:{&quot;width&quot;:1230,&quot;height&quot;:1400},&quot;sport&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;Karate&quot;,&quot;Taekwondo&quot;],&quot;default&quot;:&quot;Karate&quot;,&quot;tsType&quot;:&quot;string&quot;},&quot;showTicker&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;}}">
class Component extends DCLogic {
state = {
t: 107, aka: 6, ao: 4, menu: false,
parts: { scorebar: true, ticker: true, identity: true, timeline: true, clubs: true, flags: true, penalties: false }
};
playIntro = () => {
clearTimeout(this.t1); clearTimeout(this.t2);
this.setState({ intro: true, introFade: false });
this.t1 = setTimeout(() => this.setState({ introFade: true }), 2000);
this.t2 = setTimeout(() => this.setState({ intro: false }), 2600);
};
componentDidMount() {
this.playIntro();
this.iv = setInterval(() => this.setState(s => ({ t: s.t > 0 ? s.t - 1 : 0 })), 1000);
}
componentWillUnmount() { clearInterval(this.iv); }
renderVals() {
const tkd = (this.props.sport ?? 'Karate') === 'Taekwondo';
const RED = '#ff6a5e', BLUE = '#6aa6ff';
const ev = [
{ at: 11, corner: 'blue', pts: 1 }, { at: 11, corner: 'red', pts: 1 },
{ at: 30, corner: 'red', pts: 2 }, { at: 51, corner: 'red', pts: 1 },
{ at: 112, corner: 'blue', pts: 1 }, { at: 159, corner: 'blue', pts: 1 }
];
const aka = ev.filter(e => e.corner === 'red').reduce((a, e) => a + e.pts, 0);
const ao = ev.filter(e => e.corner === 'blue').reduce((a, e) => a + e.pts, 0);
const p = this.state.parts;
const m = Math.floor(this.state.t / 60), s = String(this.state.t % 60).padStart(2, '0');
// mirrors the platform's Points data: round, @timestamp, corner, point value
const events = [
{ at: 11, corner: 'blue', pts: 1 }, { at: 11, corner: 'red', pts: 1 },
{ at: 30, corner: 'red', pts: 2 }, { at: 51, corner: 'red', pts: 1 },
{ at: 112, corner: 'blue', pts: 1 }, { at: 159, corner: 'blue', pts: 1 }
];
const fmt = n => Math.floor(n / 60) + ":" + String(n % 60).padStart(2, '0');
const karateFeed = events.slice().reverse().slice(0, 4).map(e => ({
time: "@" + fmt(e.at),
name: e.corner === 'red' ? "Point · Red" : "Point · Blue",
pts: "+" + e.pts,
color: e.corner === 'red' ? RED : BLUE
}));
const totals = events.reduce((a, e) => (a[e.corner] += e.pts, a), { red: 0, blue: 0 });
const tkdFeed = [
{ time: "1:12", name: "Head kick", pts: "+3", color: RED },
{ time: "1:44", name: "Turning body", pts: "+4", color: BLUE },
{ time: "2:03", name: "Body kick", pts: "+2", color: RED },
{ time: "2:21", name: "Gam-jeom", pts: "+1", color: BLUE }
];
return {
disciplineLabel: tkd ? "Taekwondo Kyorugi" : "Karate Kumite",
overlayDisplay: 'contents',
introDisplay: this.state.intro ? 'block' : 'none',
introOpacity: this.state.introFade ? 0 : 1,
replayIntro: this.playIntro,
menuDisplay: this.state.menu ? 'flex' : 'none',
gearColor: this.state.menu ? '#e8534a' : '#efe9e0',
toggleMenu: () => this.setState(s => ({ menu: !s.menu })),
menuItems: [
['scorebar', 'Score bar'], ['ticker', 'Live scoring feed'], ['identity', 'Match info'],
['timeline', 'Point timeline'], ['clubs', 'Club logos'], ['flags', 'Country flags'], ['penalties', 'Penalties']
].map(([k, label]) => ({
label,
toggle: () => this.setState(s => ({ parts: Object.assign({}, s.parts, { [k]: !s.parts[k] }) })),
track: p[k] ? '#e8534a' : '#3a3734',
knob: p[k] ? '14px' : '2px'
})),
dIdentity: p.identity ? 'flex' : 'none',
dTicker: p.ticker ? 'flex' : 'none',
dBar: p.scorebar ? 'flex' : 'none',
dTimeline: p.timeline ? 'flex' : 'none',
dClubs: p.clubs ? 'flex' : 'none',
dFlags: p.flags ? 'flex' : 'none',
dPenalty: p.penalties ? 'inline-block' : 'none',
redCorner: "Red",
blueCorner: "Blue",
penaltyLabel: tkd ? "GAM-JEOM 1" : "C1",
roundLabel: "Round 1",
penaltyLabel2: null,
akaScore: aka,
aoScore: ao,
clock: "2:39",
feed: (this.props.showTicker ?? true) ? (tkd ? tkdFeed : karateFeed) : [],
timeline: [RED, '#2a2724', BLUE, RED, '#2a2724', '#2a2724', BLUE, RED, '#2a2724', RED, BLUE, '#2a2724', RED, '#2a2724']
.map(c => ({ color: c })),
statusLine: aka > ao ? "Red leads +" + (aka - ao) : ao > aka ? "Blue leads +" + (ao - aka) : "Level"
};
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,102 @@
# TASK: Add a match scoreboard overlay + VS intro to the video player on video.takeone.bh
Implement this end to end in this codebase, in the existing player and video page. Follow the codebase's own framework, component and styling patterns. Do not ship the reference HTML as-is — recreate it.
Reference prototype (design intent, exact colors/geometry): `Match Points Overlay.dc.html` in this folder. Open it in a browser: it shows the standalone VS card, then the player frame with the VS intro, score bar, live scoring feed, point timeline, control bar and gear menu.
---
## 1. What exists today (do not rebuild)
- **Points panel** on the video page: rounds (number, optional name, optional start time) and point events shaped `@mm:ss · Point (N pt) · Blue | Red | Both`, with a running score printed `Score <blue> <red>`.
- **Coach review** panel: time-ranged notes with author, plus reaction icons.
- **Player**: progress bar, play/pause, volume, time `0:57 / 4:11`, gear (settings) menu, loop, mini player (PiP), theater, fullscreen; playback-speed menu and ¼× ½× ¾× slow motion; a "Highlights" badge top-right.
## 2. What to add to the data model
A **Match metadata** form for sports videos, next to "Add Round":
- Event/series name, division/category, weight class, bout number, tatami/ring, date, venue.
- Per corner (red, blue): athlete name, country name + ISO code, flag image, club name, club logo, optional headshot (3:4).
Rules:
- Every field is optional. **An empty field hides its element.** Never render placeholder strings ("NOT SET", "—", empty boxes).
- No athlete name → show the corner label ("RED" / "BLUE") in the name slot.
- No flag image → hide the flag chip. No club logo → hide the logo slot. No headshot → hide the photo frame and center the text block.
- Corners in data stay **red** and **blue**. Display labels are a per-video option: Red/Blue (default), Aka/Ao (karate), Hong/Chung (taekwondo).
- A `Both` point event increments both corners and emits two feed rows.
## 3. Overlay: driven by playback time
The overlay is a pure function of `video.currentTime`. No timers, no countdown. Slow motion and playback speed must not affect it; scrubbing backwards must remove points again.
- **Scores** = sum of point values for events with `at <= currentTime`, per corner.
- **Round label** = the round whose start time is the latest `<= currentTime`.
- **Clock** = `currentTime` as `m:ss` (offset from round start when round start times exist).
- **Live scoring feed** = last 4 events with `at <= currentTime`, newest on top, each animating in (translateY 10px → 0, opacity 0 → 1, .45s ease) as playback crosses it.
- **Point timeline** = one segment per event across the full duration, red or blue, positioned by timestamp; empty segments `#2a2724`.
## 4. Layout spec (design width 1150px, frame 16:9)
Overlay layer: `position:absolute; inset:0; pointer-events:none;` inside the player container. Only the gear menu is interactive. Scrims: bottom `rgba(6,6,8,.9) → .45 @15% → 0 @30%`, top `rgba(6,6,8,.85) → .4 @14% → 0 @30%`. No texture/grain layer over the footage.
**Match info** — top-left, 22px/26px inset: 4px vertical rule `linear-gradient(#e8534a,#7c1d18)`; discipline line in `Zen Old Mincho` 15px, .34em tracking, `#efe9e0`; subtitle 13px, .28em, `#cfc9c1`, with `text-shadow: 0 1px 6px rgba(0,0,0,.9)`.
**Live scoring feed** — top-right, 20px/24px inset, 262px wide. Header: 7px red dot pulsing (opacity 1→.25→1, 1.6s) + "LIVE SCORING" 12px, .3em, `#d5cfc7`. Rows: `rgba(10,10,12,.62)` + `blur(6px)`, padding 7px/10px, `border-right:3px solid <corner color>`; timestamp 12px `#79736c` · label 17px/700 .12em uppercase `#efe9e0` · points in `Zen Old Mincho` 19px in the corner color.
**Score bar** — 84px tall, inset 26px left/right, `bottom: 56px` (clears the 48px control band), z-index below controls. Three panels, no gaps:
- Red panel: `flex:1 1 0; min-width:0; overflow:hidden; transform:skewX(-9deg)`, background `linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9))`, `border-bottom:3px solid #ff6a5e`.
Inside, ONE inner row un-skews: `transform:skewX(9deg); height:100%; padding:0 16px; display:flex; align-items:center; gap:12px`. Never counter-skew individual children — that is what made scores collide with names in the first attempt.
Children: club logo 46×46 (`rgba(0,0,0,.32)`, 1px white 24% border) → text column (`flex:1 1 auto; min-width:0`) → score box (`width:60px; flex:none`, column, right-aligned).
Text column row 1: flag chip 26×17 + athlete name (25px/800 uppercase, `flex:1 1 auto; min-width:0`, nowrap + ellipsis). Row 2: club name (12px, .03em, uppercase, ellipsis) + penalty chip.
Score box: corner label 11px .24em above the numeral; numeral `Zen Old Mincho` 46px/700 white, `text-shadow:0 6px 20px rgba(0,0,0,.5)`.
- Center: `width:118px; flex:none`, `rgba(9,9,11,.9)` + `blur(8px)`, `border-bottom:3px solid #2c2a28`; round label 11px .3em `#8f8a83`, clock 34px/800 `#efe9e0` tabular-nums, three 20×3px pips (`#e8534a` done, `#3a3734` pending).
- Blue panel: exact mirror — score box, text column right-aligned, club logo. Gradient `rgba(30,72,140,.9) → rgba(18,44,92,.94)`, `border-bottom:3px solid #6aa6ff`.
Both panels must be true mirrors: logo → flag+name → club+penalty → score, with the corner label above its own score on both sides.
**Point timeline** — `left/right: 26px; bottom: 48px; height: 5px`, segments with 2px gaps.
## 5. Gear-menu controls
No floating on-screen toggle. Add a **Scoreboard** section to the existing gear menu (same pattern as quality/speed), anchored above the gear button:
Panel `rgba(10,10,12,.94)` + `blur(10px)`, 1px white 16% border, 236px wide, header "SCOREBOARD" 11px .3em `#8f8a83`. One switch row per part:
`Score bar · Live scoring feed · Match info · Point timeline · Club logos · Country flags · Penalties`
Rows: 14px uppercase label, .14em tracking, hover `rgba(255,255,255,.07)`; switch = 26×14 track (`#e8534a` on, `#3a3734` off) with a 10px knob. Persist per user; the uploader's per-video settings seed the defaults. **Penalties defaults off** — the Points panel has no penalty events yet; enable when it does. Gear icon turns `#e8534a` while the menu is open. Menu closes on outside click and Esc, and never covers the score bar.
## 6. VS intro before playback
Part of the play experience, inside the video box at 16:9:
- On first play of a session, render the VS card over the frame while the video is paused at frame 0.
- Hold **2s**, then fade out over 0.5s; call `video.play()` as the fade begins so motion is already running when the card clears. Scoreboard fades in after.
- Any click, tap or key press skips it immediately and starts playback.
- Replays and scrubs do not re-trigger it. If match metadata is missing, skip the intro entirely.
**VS card content**: event name in `Zen Old Mincho` 24px .44em uppercase; division line 12px .34em with red/blue 4px square separators; per fighter a **3:4 photo frame** with a **104px circular club crest** overlapping its inner-bottom corner (`bottom:-26px`, `#0d0d10`, 1px white 28% border, `0 10px 30px rgba(0,0,0,.6)`), then a 3px corner-colored rule, name 42px/800 uppercase, flag chip 40×27 + country 14px .3em, club name 13px .18em; center column: "VS" `Zen Old Mincho` 88px, weight class in a 1px-bordered chip, format line; bottom line: bout · tatami · date · venue.
**Background animation** (CSS only, no video): two radial glows — red `rgba(168,42,34,.62)` and blue `rgba(28,64,136,.62)`, 70% width, drifting `translate3d(±60px, ∓28px) scale(1→1.14)` over 19s and 23s ease-in-out infinite — plus a 26%-wide white 9% light sweep crossing every 11s. No center divider line.
Also expose the VS card as a 1200×630 share image for `og-image`.
## 7. Design tokens
Ink `#0a0a0c` · panel `rgba(9,9,11,.9)` · bone `#efe9e0` · muted `#8f8a83` · brand red `#e8534a` · red edge `#ff6a5e` · red panel `#7a1a16 → #b4342c` · blue panel `#1e488c → #122c5c` · blue edge `#6aa6ff` · senshu `#ffdf6b` on `#3a2a00` · penalty `#c8492f`.
Type: **Barlow Condensed** 400/600/700/800 for all UI; **Zen Old Mincho** 700 for score numerals, "VS" and the discipline/event lines.
Geometry: skew `-9deg` with a single `+9deg` counter-skew row; bar 84px; frame inset 26px; gaps 12px; no border radius on overlay parts.
## 8. Acceptance criteria
1. Overlay never blocks the scrubber or any control (`pointer-events:none`, controls above it).
2. Score bar sits fully above the control band; nothing crosses the progress bar or the panels' bottom borders.
3. No text is clipped or overlapping at 1150px design width: every text span has `min-width:0` with ellipsis; fixed boxes are `flex:none`.
4. Red and blue panels are exact mirrors.
5. Scores, feed, round and timeline all match the Points panel at any `currentTime`, including after scrubbing backwards and at ¼×/½×/¾× speed.
6. Missing metadata hides elements — no placeholder text anywhere.
7. Overlay scales with the player (fixed 1150px layer scaled, or clamp-based sizing); minimum rendered text 12px; below ~700px player width only the score bar remains.
8. VS intro holds 2s, fades, playback starts; skippable; first play only.
9. Every one of the seven parts toggles independently from the gear menu and persists per user.

View File

@ -0,0 +1,200 @@
# Handoff: Live Match Scoreboard Overlay (Karate / Taekwondo)
Paste this file to Claude Code as the brief. The bundled `Match Points Overlay.dc.html` is a **design reference prototype written in HTML** — not production code. Recreate it inside the existing video platform (React/Vue/whatever the player is built in), using the codebase's own component and styling patterns. Fidelity: **high** — colors, type, sizes below are final.
## Goal
An overlay that sits on top of the video player and shows live match state for a karate kumite or taekwondo kyorugi bout. **Every part must be independently switchable on and off** (per-viewer preference, and per-broadcast defaults set by the uploader).
## Structure
The overlay is a layer inside the player container:
```
<div class="player"> position: relative
<video> base layer
<div class="overlay"> position:absolute; inset:0; pointer-events:none
...parts... each part pointer-events:none except the toggle chip
</div>
</div>
```
Nothing about the video element changes. The overlay never intercepts clicks except on its own toggle control.
## Switchable parts
Each is a boolean, defaulting to on, stored per user (localStorage or profile) and overridable per video:
| key | part | position in frame |
|---|---|---|
| `identity` | discipline label + match subtitle, with a 4px red-gradient rule at its left | top-left, 22px / 26px inset |
| `ticker` | "LIVE SCORING" header + last 4 judged calls | top-right, 20px / 24px inset, 262px wide |
| `scorebar` | the main bottom bar (both corner panels + center clock) | bottom, full width minus 26px, height 84px, 18px from bottom |
| `timeline` | thin segmented point-history strip | bottom, 5px tall, 8px from bottom |
| `clubs` | club logo slot + club name inside each corner panel | inside scorebar |
| `flags` | country flag chip next to each athlete name | inside scorebar |
| `penalties` | SENSHU chip (red) and C1 / GAM-JEOM chip (blue) | inside scorebar |
Also a master `overlay` toggle: a chip in the video box (bottom-right, `SCOREBOARD ON/OFF`, dark translucent, 1px white 18% border, red dot when on, grey when off) that hides everything at once. This chip is the only pointer-events:auto element. When the overlay is off, the chip drops from 112px above the bottom to 18px.
Where the platform has a player settings menu (like captions), expose the same booleans there.
## Layout spec (design width 1150px, 16:9-ish frame)
Scrim over the video so text reads: bottom gradient `rgba(6,6,8,.9) 0% → .45 at 15% → 0 at 30%`, top gradient `rgba(6,6,8,.85) 0% → .4 at 14% → 0 at 30%`. A diagonal texture layer over the frame: `repeating-linear-gradient(115deg, rgba(255,255,255,.5) 0 1px, transparent 1px 4px)`, opacity .16, `mix-blend-mode: overlay`.
### Bottom bar (84px tall, flex row, no gaps between panels)
- **Red panel**`flex:1 1 0; min-width:0; overflow:hidden; transform:skewX(-9deg)`, background `linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9))`, `border-bottom:3px solid #ff6a5e`.
Inner row un-skews once: `transform:skewX(9deg); height:100%; padding:0 16px; display:flex; align-items:center; gap:12px`. Applying the counter-skew to the row (not to each child) is what keeps the score numeral from overlapping the name.
Children in order: club logo box (46×46, `rgba(0,0,0,.32)` fill, 1px white 24% border, monospace 8px "CLUB LOGO" placeholder) → text column (`flex:1 1 auto; min-width:0`) → score box (`width:56px; flex:none; text-align:right`).
Text column row 1: flag chip 26×17 → athlete name (25px/800, uppercase, `flex:1 1 auto; min-width:0`, nowrap + ellipsis) → corner label "AKA" (11px, .24em tracking, 66% white).
Text column row 2: club name (12px, .03em tracking, 85% white, uppercase, ellipsis) → SENSHU chip (10px/700, .18em, 1px 6px padding, `#ffdf6b` on `#3a2a00`).
- **Center clock**`width:118px; flex:none`, `rgba(9,9,11,.9)` + `backdrop-filter: blur(8px)`, `border-bottom:3px solid #2c2a28`. Stacked: round label (11px, .3em, `#8f8a83`), clock `mm:ss` (34px/800, `#efe9e0`, tabular-nums), three 20×3px round pips (`#e8534a` for completed, `#3a3734` for pending).
- **Blue panel** — mirror of red: gradient `rgba(30,72,140,.9) → rgba(18,44,92,.94)`, `border-bottom:3px solid #6aa6ff`, order reversed (score, text column right-aligned, club logo), penalty chip `#c8492f` on white text.
- Score numerals: `'Zen Old Mincho', serif`, 46px, weight 700, white, `text-shadow: 0 6px 20px rgba(0,0,0,.5)`.
### Ticker (top-right)
Header row: 7px red dot pulsing (`opacity 1 → .25 → 1`, 1.6s infinite) + "LIVE SCORING" (12px, .3em, `#d5cfc7`, text-shadow for legibility). Each entry: `rgba(10,10,12,.62)` + `blur(6px)`, 7px/10px padding, `border-right: 3px solid <corner color>`, animating in with `translateY(10px) → 0`, opacity 0 → 1, .45s ease. Entry content: timestamp (12px, `#79736c`) · call name (17px/700, .12em, uppercase, `#efe9e0`) · points (`Zen Old Mincho` 19px, corner color). Newest at top, keep 4.
Karate calls: Ippon +3, Waza-ari +2, Yuko +1, Chukoku/Keikoku/Hansoku penalties.
Taekwondo calls: head kick +3, turning body +4, body kick +2, punch +1, gam-jeom (awards +1 to the opponent).
### Timeline strip
14 equal segments, 5px tall, 2px gaps; red / blue for scored points in chronological order, `#2a2724` for empty. Grows as the bout progresses.
## Sport switch
One `sport` setting flips labels without changing layout:
| | Karate | Taekwondo |
|---|---|---|
| discipline label | KARATE KUMITE | TAEKWONDO KYORUGI |
| corners | AKA / AO | HONG / CHUNG |
| round label | KUMITE | ROUND n / 3 |
| penalty chip | C1 | GAM-JEOM n |
| point names | Ippon / Waza-ari / Yuko | head, turning, body, punch |
## Data contract
Drive the overlay from the existing Points panel. Suggested shape:
```ts
type Corner = 'red' | 'blue';
interface Athlete { name: string; countryCode: string; flagUrl?: string; clubName: string; clubLogoUrl?: string; }
interface ScoreEvent { id: string; at: string; corner: Corner; label: string; points: number; }
interface MatchState {
sport: 'karate' | 'taekwondo';
title: string; subtitle: string; // "Finals", "Senior 75 kg · Tatami 1"
red: Athlete; blue: Athlete;
redScore: number; blueScore: number;
senshu: Corner | null;
penalties: { red: number; blue: number };
round: number; totalRounds: number;
clockSeconds: number; running: boolean;
events: ScoreEvent[];
}
```
Scores, clock and events come from the scorekeeper; the overlay is presentational. The clock counts down once per second while `running`. New events prepend to the ticker and push a segment onto the timeline.
## Design tokens
Colors: ink `#0a0a0c` / panel `rgba(9,9,11,.9)` / bone `#efe9e0` / muted `#8f8a83` / brand red `#e8534a` / red edge `#ff6a5e` / red panel `#7a1a16 → #b4342c` / blue panel `#1e488c → #122c5c` / blue edge `#6aa6ff` / senshu `#ffdf6b` on `#3a2a00` / penalty `#c8492f`.
Type: `Barlow Condensed` 400/600/700/800 for all UI; `Zen Old Mincho` 700 for score numerals and the discipline label.
Geometry: panel skew `-9deg` (content counter-skewed `+9deg`), bar height 84px, frame inset 26px, bottom inset 18px, gaps 12px, no border radius on overlay parts.
## Accessibility / safety
- Keep every text span `min-width:0` inside its flex column so names truncate instead of overflowing.
- Minimum on-screen text size 12px at 1150px design width; scale the whole overlay proportionally with the player (`transform: scale()` on a fixed 1150px layer, or clamp-based sizing) so it stays legible at small player sizes and in fullscreen.
- Below ~700px player width, hide `identity` and `ticker` automatically and keep only the score bar.
- Overlay is decorative for screen readers; expose the same data as text in the existing Points panel.
## Files
- `Match Points Overlay.dc.html` — the reference prototype (open in a browser; the SCOREBOARD chip demonstrates the master toggle).
- `assets/mat.png` — still frame used as the video stand-in. Not part of the deliverable.
- Flags and club logos are mockup placeholders in the prototype; wire real images in.
---
## Corrections after first implementation pass
The first build broke the score bar. Specific fixes:
1. **Keep the skew, but counter-skew ONCE.** The panel gets `transform: skewX(-9deg)`; a single inner row gets `transform: skewX(9deg); height:100%; display:flex; align-items:center`. Do not apply the counter-skew to individual children, and do not drop the skew altogether — the parallelogram edges are the design.
2. **The bar is a fixed-height 84px strip, vertically centered content.** In the broken build the names sat on the very bottom edge and the panels were full-bleed to the frame. The bar is inset 26px left/right, sits above the player control bar, and its content is vertically centered — nothing touches the bar's top or bottom edge.
3. **Both scores must render.** Red score right-aligned in a 56px `flex:none` box at the panel's inner end; blue score left-aligned in the mirrored position. In the broken build the red score was missing and the blue "1" was floating at the top edge.
4. **Never let panel content overflow.** Text spans need `min-width:0` and `overflow:hidden; text-overflow:ellipsis`; fixed boxes (logo 46px, score 56px) are `flex:none`.
5. **Empty data:** when an athlete/club is unknown, render the corner as "—" or hide the row — do not print "NOT SET".
6. **Club logo and flag slots must be filled or hidden.** If no image, hide the slot (toggle default off) rather than showing an empty box.
## Controls belong in the player's gear menu
Remove any floating on-screen toggle chip. The scoreboard switches live in the **existing player settings (gear) menu**, as a "Scoreboard" section — same pattern as quality/captions:
- Gear button in the control bar opens a popup anchored bottom-right, above the control bar.
- Popup: dark translucent panel (`rgba(10,10,12,.94)` + `blur(10px)`, 1px white 16% border), 236px wide, a "SCOREBOARD" header (11px, .3em tracking, `#8f8a83`), then one switch row per part: Score bar, Live scoring feed, Match info, Point timeline, Club logos, Country flags, Penalties.
- Rows: 14px label, uppercase, .14em tracking; switch is a 26×14 track (`#e8534a` on, `#3a3734` off) with a 10px knob; hover `rgba(255,255,255,.07)`.
- Persist each switch per user; the uploader's per-video defaults seed them.
- The popup closes on outside click / Esc, and never covers the score bar.
The updated prototype in this folder demonstrates the gear menu and all seven switches.
---
## Target: video.takeone.bh video page (e.g. /videos/11vu7R)
Studied the live page. The overlay must be driven by what the platform already stores, and by **playback time** — not by a live clock.
### What the platform already has
- **Points panel**: rounds (number, name, optional start time) and point events shaped like `@00:11 · Point (1 pt) · Blue|Red|Both` with a running score printed as `Score <blue> <red>`.
- **Coach review**: time-ranged notes (`01:40 — 01:45`, text, author) and reaction icons.
- **Player**: Highlights badge, mini player, playback speed menu, ¼× ½× ¾× slow-motion buttons, scrub speeds 1× 5× 20× in the points editor.
- **No fighter metadata at all** — no athlete names, clubs, flags, weight class, or event name. This is exactly why the first implementation printed "NOT SET".
### Required changes on the platform side
1. **Add match metadata to the video's sports settings** (one form, next to Add Round): event name, division/category, weight class, bout number, tatami/ring, date, venue — and per corner: athlete name, country (ISO code + flag image), club name, club logo, plus optional headshot for the VS card.
2. **Any field left empty hides its element.** Never render a placeholder string like "NOT SET". If no flag → hide the flag chip; no club logo → hide the logo slot; no athlete name → fall back to "RED" / "BLUE".
3. **Corners are Red and Blue** in the existing data. Karate labels (AKA/AO) or taekwondo (HONG/CHUNG) are a display option, not new data.
4. **A "Both" point event** awards to both corners — increment both scores and emit two ticker rows.
### Binding to playback
The overlay is a pure function of `video.currentTime`:
- Scores = sum of point values for events with `at <= currentTime`, per corner.
- Round label = the round whose start time is the latest `<= currentTime`.
- Clock = `currentTime` formatted mm:ss (offset from the round start if round start times exist). It does **not** count down.
- Ticker = the last 4 events with `at <= currentTime`, newest on top; a new event animates in as playback crosses it. Scrubbing backwards removes them again.
- Timeline strip = one segment per point event across the video duration, red/blue, positioned by timestamp — clicking a segment can seek to that point (nice-to-have; keep `pointer-events` off otherwise).
- Slow-motion (¼× ½× ¾×) and playback speed must not affect the overlay: it reads `currentTime`, never a timer.
### Pre-match VS card
Rendered as a poster/intro layer at 16:9 over the player before playback starts (and available as an exportable share image at 1200×630 for the og-image). Content: event name, division line, both fighters' 3:4 photos with 104px circular club crests overlapping the inner corner, name, flag + country, club, the weight class under the VS mark, and a bout/tatami/date line. Background: two slow drifting red/blue radial glows plus a periodic light sweep — CSS animations only, no video.
### Controls
Everything toggles from the **gear menu** in the existing control bar, under a "Scoreboard" section: Score bar, Live scoring feed, Match info, Point timeline, Club logos, Country flags, Penalties. Penalties defaults **off** because the current data model has no penalty events; enable it when penalties are added to the Points panel. The overlay layer is `pointer-events:none` so the scrubber and buttons stay clickable, and the score bar sits above the 48px control band (bottom ≈ 56px) so nothing crosses the progress bar.
The prototype in this folder uses the real point data from /videos/11vu7R (blue 1 @0:11, red 1 @0:11, red 2 @0:30, red 1 @0:51, blue 1 @1:52, blue 1 @2:39 → 43 red).
## VS intro before playback
The VS card is part of the play experience, not a separate screen:
- On load / on pressing play, the player renders the VS card **inside the video box** at 16:9, covering the frame, while the video is buffering/paused at frame 0.
- It holds for **2 seconds**, then fades out over ~0.5s and playback starts (`video.play()` fires as the fade begins, so the first frames are already moving as the card clears).
- The scoreboard overlay fades in after the card clears.
- The card is skippable: any click, key press, or tap on the frame ends it immediately and starts playback.
- Only on the first play of a session — a replay/scrub does not re-trigger it (the prototype's play button replays it for demo purposes).
- If match metadata is missing, skip the intro entirely rather than showing an empty card.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

94
drafts/vs-screen.html Normal file
View File

@ -0,0 +1,94 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>VS screen — exact markup</title>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@700&display=swap" rel="stylesheet">
<style>
body { margin:0; background:#0a0a0c; font-family:'Barlow Condensed',sans-serif; }
@keyframes driftA { 0%,100% { transform: translate3d(0,0,0) scale(1); } 50% { transform: translate3d(60px,-28px,0) scale(1.14); } }
@keyframes driftB { 0%,100% { transform: translate3d(0,0,0) scale(1.08); } 50% { transform: translate3d(-54px,26px,0) scale(1); } }
@keyframes sweep { 0% { transform: translateX(-60%) skewX(-14deg); opacity:0; } 25% { opacity:.5; } 60% { opacity:0; } 100% { transform: translateX(160%) skewX(-14deg); opacity:0; } }
</style>
</head>
<body>
<!--
VS SCREEN — copy verbatim. Replace ONLY the {{ }} holes.
Sits inside the player container as an absolutely positioned 16:9 layer:
position:absolute; inset:0; z-index:8; opacity 1 -> 0 over .55s after a 2s hold.
Photo frames are 3:4. The club crest overlaps the photo's INNER-bottom corner (right on the left fighter, left on the right one).
Hide a missing element with display:none — never substitute placeholder text.
The three animated background layers are decorative; keep them behind everything (they are first in source order).
-->
<div style="position:relative;width:1150px;aspect-ratio:16/9;overflow:hidden;background:#08080a">
<div style="position:absolute;inset:-12%;width:70%;left:-6%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);animation:driftA 19s ease-in-out infinite"></div>
<div style="position:absolute;inset:-12%;width:70%;left:36%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);animation:driftB 23s ease-in-out infinite"></div>
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:sweep 11s linear infinite"></div>
<div style="position:absolute;top:0;bottom:0;left:0;width:54%;clip-path:polygon(0 0, 100% 0, 78% 100%, 0 100%);background:linear-gradient(120deg, rgba(122,26,22,.5), rgba(8,8,10,0) 76%)"></div>
<div style="position:absolute;top:0;bottom:0;right:0;width:54%;clip-path:polygon(22% 0, 100% 0, 100% 100%, 0 100%);background:linear-gradient(300deg, rgba(24,52,110,.5), rgba(8,8,10,0) 76%)"></div>
<!-- event header -->
<div style="position:absolute;top:34px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-family:'Zen Old Mincho',serif;font-size:24px;letter-spacing:.44em;color:#efe9e0;text-transform:uppercase;text-indent:.44em">{{ eventName }}</div>
<div style="display:flex;align-items:center;gap:14px;font-size:12px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">
<span>{{ category }}</span><span style="width:4px;height:4px;background:#e8534a"></span><span>{{ division }}</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>{{ roundName }}</span>
</div>
</div>
<div style="position:absolute;inset:96px 0 54px;display:grid;grid-template-columns:1fr 200px 1fr;align-items:start">
<!-- RED fighter -->
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
<div style="position:relative;width:228px;height:304px">
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div style="position:absolute;bottom:-26px;right:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
</div>
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ redName }}</div>
<div style="display:flex;align-items:center;gap:12px">
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
<span style="font-size:14px;letter-spacing:.3em;color:#d3b3ad;text-transform:uppercase">{{ redCountry }}</span>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:2px">
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ redClub }}</span>
</div>
</div>
</div>
<!-- centre -->
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding-top:96px">
<span style="font-family:'Zen Old Mincho',serif;font-size:88px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 34px rgba(0,0,0,.75)">VS</span>
<span style="padding:7px 16px;border:1px solid rgba(255,255,255,.3);font-size:14px;letter-spacing:.32em;color:#efe9e0;text-transform:uppercase">{{ weightClass }}</span>
<span style="font-size:11px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">{{ format }}</span>
</div>
<!-- BLUE fighter — mirror -->
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
<div style="position:relative;width:228px;height:304px">
<div style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
<div style="position:absolute;bottom:-26px;left:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
</div>
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:8px">
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ blueName }}</div>
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:14px;letter-spacing:.3em;color:#a8b6cf;text-transform:uppercase">{{ blueCountry }}</span>
<span style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:2px">
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ blueClub }}</span>
</div>
</div>
</div>
</div>
<!-- bout line -->
<div style="position:absolute;bottom:26px;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:16px;font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">
<span>{{ bout }}</span><span style="width:4px;height:4px;background:#e8534a"></span><span>{{ tatami }}</span><span style="width:4px;height:4px;background:#6aa6ff"></span><span>{{ dateVenue }}</span>
</div>
</div>
</body>
</html>

2537
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,8 +9,5 @@
"axios": "^1.6.4", "axios": "^1.6.4",
"laravel-vite-plugin": "^1.0.0", "laravel-vite-plugin": "^1.0.0",
"vite": "^5.0.0" "vite": "^5.0.0"
},
"dependencies": {
"puppeteer": "^24.43.1"
} }
} }

View File

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

View File

@ -1,40 +0,0 @@
@extends('admin.layout')
@section('title', 'OG Previews')
@section('content')
<div style="padding: 24px;">
<h1 style="margin: 0 0 8px; color: #fff;">Generated VS Cards</h1>
<p style="color: #aaa; margin: 0 0 20px;">
These are the 1200×630 PNGs that WhatsApp, Facebook, and Twitter show
when a sports match video is shared. Generated on save; regenerated on
edit. Filename is <code>match-{video_id}-{updated_at}.png</code>.
</p>
@if($files->isEmpty())
<p style="color: #aaa;">No previews cached yet. Save a match video to generate one.</p>
@else
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px;">
@foreach($files as $f)
<div style="background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden;">
<a href="{{ route('admin.og-previews.file', $f['name']) }}" target="_blank" style="display: block; background: #000;">
<img src="{{ route('admin.og-previews.file', $f['name']) }}"
alt="{{ $f['name'] }}"
style="display: block; width: 100%; height: auto;">
</a>
<div style="padding: 12px 14px; color: #ccc; font-size: 13px;">
<div style="font-weight: 600; color: #fff;">Video #{{ $f['id'] }}</div>
<div style="color: #888; margin-top: 4px; font-family: monospace; font-size: 11px; word-break: break-all;">
{{ $f['name'] }}
</div>
<div style="color: #888; margin-top: 4px;">
{{ number_format($f['size'] / 1024, 1) }} KB &middot;
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endsection

View File

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

View File

@ -3,38 +3,9 @@
/videos?filter=playlists. Wrapped in @once so it emits only once. --}} /videos?filter=playlists. Wrapped in @once so it emits only once. --}}
@once @once
<style> <style>
/* Base styles for video card every card in the grid renders at the same /* Base styles for video card */
overall size regardless of type (video / shorts / match / playlist). The
grid handles equal widths; height parity is enforced here by making the
card a column-flex container, the thumb a strict 16/9 box, and every
text row a fixed line-height so missing channel/meta rows never shrink
the card. */
.yt-video-card { .yt-video-card {
cursor: pointer; cursor: pointer;
display: flex;
flex-direction: column;
}
/* When the 3-dot menu is open, promote this card above its siblings so
the dropdown-menu (Bootstrap default z-index 1000) isn't visually
covered by later cards in the grid. Cards are position:static by
default, so siblings later in the DOM naturally paint on top even
though the dropdown itself has a high z-index the stacking is
compared at the card level, not the dropdown level. `:has()` triggers
only while a menu is `.show`ing, so this doesn't affect the normal
grid layout. */
.yt-video-card:has(.dropdown-menu.show) {
position: relative;
z-index: 50;
}
/* Same fix for the menu container .yt-video-info has overflow:hidden
(to hard-cap card height for grid parity), which was clipping the
dropdown's bottom edge. Allow the dropdown to escape by giving its
wrapper a stacking context and letting overflow leak upward from that
ancestor only when a menu is open. */
.yt-video-card .yt-video-info:has(.dropdown-menu.show) {
overflow: visible;
} }
.yt-video-card .yt-video-thumb { .yt-video-card .yt-video-thumb {
@ -90,11 +61,7 @@
.yt-video-card .yt-video-thumb video { .yt-video-card .yt-video-thumb video {
width: 100%; width: 100%;
height: 100%; height: 100%;
/* Match the still-image thumbnail: always cover the 16/9 box so object-fit: contain;
portrait and landscape sources render at the same visible size,
instead of letterboxing (which made portrait previews look
smaller than landscape ones). */
object-fit: cover;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -245,19 +212,6 @@
display: flex; display: flex;
margin-top: 12px; margin-top: 12px;
gap: 12px; gap: 12px;
/* Hard-cap the info block to guarantee every card has the same total
height (thumb + info). Overflow hidden so any accidental extra
row can't push the card taller than its neighbours. */
height: 76px;
overflow: hidden;
}
.yt-video-card .yt-video-details {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
} }
.yt-video-card .yt-channel-icon { .yt-video-card .yt-channel-icon {
@ -283,28 +237,20 @@
font-weight: 500; font-weight: 500;
color: #fff; color: #fff;
margin: 0 0 4px; margin: 0 0 4px;
line-height: 1.3; display: -webkit-box;
/* Single-line, no wrapping — overflow ellipses. */ -webkit-line-clamp: 2;
white-space: nowrap; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; line-height: 1.3;
} }
.yt-video-card .yt-video-title a { .yt-video-card .yt-video-title a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
/* Block-level with nowrap so ellipsis actually clips the excess. display: flex;
Inline-flex would let the children overflow past the parent. */ align-items: baseline;
display: block; gap: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
} }
/* Inline children (flags, corner-colored fighter spans) must not break
the single-line layout. */
.yt-video-card .yt-video-title a > * { vertical-align: middle; }
.yt-video-card .yt-video-title a .fi { display: inline-block; }
.vc-lang-flag { .vc-lang-flag {
display: inline-block; display: inline-block;
width: 16px; width: 16px;
@ -328,8 +274,6 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
line-height: 22px;
height: 22px;
} }
.yt-video-card a.yt-channel-name { .yt-video-card a.yt-channel-name {

View File

@ -1,377 +0,0 @@
{{-- ══════════════════════════════════════════════════════════════════════
SCOREBAR MINI the same match scorebar shown on the full player,
rendered inside a video-card thumbnail during hover playback.
Uses the design's fixed 1150 px canvas (identical to the main player's
msb-scale block). Every card scales its own canvas via ResizeObserver
so the scorebar keeps its pixel-perfect proportions at any card size.
Static-only: fighter names, flags, clubs, corner labels, "Round 1"
and 0-0 / 0:00. No live-scoring script hover previews cover the
first few seconds of the match where those values are always the
baseline anyway.
════════════════════════════════════════════════════════════════════════ --}}
@php
$sbm_hd = $video->sportsMatch?->headerData();
$sbm_red = $sbm_hd['red'] ?? null;
$sbm_blue = $sbm_hd['blue'] ?? null;
$sbm_show = $sbm_hd && (($sbm_red['name'] ?? null) || ($sbm_blue['name'] ?? null));
@endphp
@if ($sbm_show)
@php
$sbm_redName = $sbm_red['name'] ?? 'RED';
$sbm_blueName = $sbm_blue['name'] ?? 'BLUE';
$sbm_redClub = $sbm_red['club'] ?? '';
$sbm_blueClub = $sbm_blue['club'] ?? '';
$sbm_redFlag = strtolower(trim((string) ($sbm_red['flag'] ?? '')));
$sbm_blueFlag = strtolower(trim((string) ($sbm_blue['flag'] ?? '')));
$sbm_redLogo = !empty($sbm_red['club_logo']) ? route('media.thumbnail', $sbm_red['club_logo']) : null;
$sbm_blueLogo = !empty($sbm_blue['club_logo']) ? route('media.thumbnail', $sbm_blue['club_logo']) : null;
// Live-scoring payload — same shape as the full-player scoreboard uses.
// Rounds drive the round label + per-round clock. Points give us the
// score at any t (last point ≤ t defines the score).
$sbm_rounds = $video->matchRounds()
->orderBy('round_number')
->get(['round_number', 'name', 'start_time_seconds'])
->map(fn ($r) => [
'n' => (int) $r->round_number,
'name' => (string) ($r->name ?? ''),
'start' => (float) ($r->start_time_seconds ?? 0),
])->values()->all();
if (empty($sbm_rounds)) $sbm_rounds = [['n' => 1, 'name' => '', 'start' => 0]];
$sbm_points = $video->matchPoints()
->orderBy('timestamp_seconds')
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
->flatMap(function ($p) {
// Same rule the full-player uses: a "both" event emits two feed rows
// — one per corner — so the ticker shows both fighters scoring.
$side = ($p->competitor === 'both') ? 'red' : $p->competitor;
$rows = [[
't' => (float) $p->timestamp_seconds,
'action' => (string) ($p->action ?? 'Point'),
'pts' => (int) $p->points,
'side' => $side,
'sr' => (int) ($p->score_red ?? 0),
'sb' => (int) ($p->score_blue ?? 0),
]];
if ($p->competitor === 'both') {
$rows[] = array_merge($rows[0], ['side' => 'blue', 't' => (float) $p->timestamp_seconds + 0.001]);
}
return $rows;
})->values()->all();
$sbm_sport = $sbm_hd['sport'] ?? null;
$sbm_data = json_encode(['rounds' => $sbm_rounds, 'points' => $sbm_points, 'sport' => $sbm_sport], JSON_HEX_APOS | JSON_HEX_QUOT);
@endphp
<div class="sbm" data-sbm data-sbm-state='{{ $sbm_data }}'>
{{-- Live scoring ticker sits in its OWN scaled canvas at the
top-right of the thumb. Populated by the sbm live-sync JS. --}}
<div class="sbm-feed-scale">
<div class="sbm-feed" data-sbm-feed>
<div class="sbm-feed-hdr"><span class="sbm-feed-dot"></span><span>Live scoring</span></div>
<div class="sbm-feed-list" data-sbm-feed-list></div>
</div>
</div>
<div class="sbm-scale">
{{-- ══ Scorebar markup same design + inline styles as the
overlay.blade.php `data-msb-part="scorebar"` block, but stripped
of the live-sync data-msb hooks. ══ --}}
<div style="position:absolute;left:0;right:0;bottom:14px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;pointer-events:none">
{{-- RED / AKA --}}
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
@if($sbm_redLogo) background-image:url('{{ $sbm_redLogo }}'); @endif"></div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
@if ($sbm_redFlag !== '')
<span class="fi fi-{{ $sbm_redFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
@endif
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redName }}</span>
</div>
@if ($sbm_redClub !== '')
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redClub }}</span>
</div>
@endif
</div>
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
<span data-sbm-red-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
</div>
</div>
</div>
{{-- CENTER: round + clock --}}
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
<div data-sbm-round style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
<div data-sbm-clock style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
<div style="transform:skewX(9deg);display:flex;gap:4px">
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#e8534a"></span>
<span style="width:20px;height:3px;background:#3a3734"></span>
</div>
</div>
{{-- BLUE / AO --}}
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
<span data-sbm-blue-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
</div>
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
<div style="display:flex;align-items:center;gap:10px;min-width:0">
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_blueName }}</span>
@if ($sbm_blueFlag !== '')
<span class="fi fi-{{ $sbm_blueFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
@endif
</div>
@if ($sbm_blueClub !== '')
<div style="display:flex;align-items:center;gap:8px;min-width:0;justify-content:flex-end">
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right">{{ $sbm_blueClub }}</span>
</div>
@endif
</div>
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
@if($sbm_blueLogo) background-image:url('{{ $sbm_blueLogo }}'); @endif"></div>
</div>
</div>
</div>
</div>
</div>
@endif
@once
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
<style>
/* ══ SCOREBAR MINI same 1150 px canvas as the full player scorebar.
Sits above the hover-preview <video> (z-index 6, one above the
video at 5) and only appears while the video is active. ══ */
.yt-video-thumb .sbm {
position: absolute; inset: 0;
z-index: 6;
font-family: 'Barlow Condensed', sans-serif;
color: #efe9e0;
pointer-events: none;
opacity: 0;
transition: opacity .25s ease .05s;
overflow: hidden;
-webkit-font-smoothing: antialiased;
--sbm-scale: 0.4;
}
.sbm * { box-sizing: border-box; }
/* Show only while the hover video is playing (playVideo() adds .active) */
.yt-video-thumb:has(video.active) .sbm { opacity: 1; }
.sbm .sbm-scale {
position: absolute; bottom: 0; left: 0;
width: 1150px; height: 100px;
transform-origin: bottom left;
transform: scale(var(--sbm-scale));
}
/* ── Live scoring feed (top-right) SAME 1150 px design canvas as the
full-player msb-feed, so the sizes and positions are pixel-identical.
Scaled by --sbm-scale (same as the bottom scorebar) so the feed
shrinks/grows in lockstep with the rest of the overlay. ── */
.sbm .sbm-feed-scale {
position: absolute; top: 0; left: 0;
width: 1150px; height: 100%;
transform-origin: top left;
transform: scale(var(--sbm-scale));
pointer-events: none;
}
/* Verbatim copy of .msb-feed / .msb-feed .hdr / .msb-feed .entry from
videos/partials/match/scoreboard/styles.blade.php only the class
names are prefixed .sbm- to avoid colliding with the full player
when a card is opened as a sub-page. */
.sbm .sbm-feed {
position: absolute; top: 22px; right: 24px;
width: 262px;
display: flex; flex-direction: column; gap: 7px;
}
.sbm .sbm-feed-hdr {
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
font-family: 'Barlow Condensed', sans-serif;
text-shadow: 0 1px 6px rgba(0,0,0,.9);
}
.sbm .sbm-feed-dot {
width: 7px; height: 7px; border-radius: 50%;
background: #e8534a; animation: sbmPulse 1.6s infinite;
}
.sbm .sbm-feed-list { display: flex; flex-direction: column; gap: 7px; }
.sbm .sbm-feed-entry {
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
padding: 7px 10px;
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
border-right: 3px solid transparent;
animation: sbmRiseIn .45s ease both;
}
.sbm .sbm-feed-entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; font-family: 'Barlow Condensed', sans-serif; }
.sbm .sbm-feed-entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; font-family: 'Barlow Condensed', sans-serif; }
.sbm .sbm-feed-entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
/* Hide feed on very small thumbs (matches the msb-small breakpoint scaled
down for cards below ~340 px it becomes illegible). */
.sbm.sbm-small .sbm-feed { display: none; }
@keyframes sbmPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
@keyframes sbmRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
</style>
<script>
(function () {
if (window._sbmBound) return;
window._sbmBound = true;
// ── Fit the fixed 1150 px scorebar canvas to the parent thumb ──
function fit(el) {
const host = el.parentElement;
if (!host) return;
const w = host.clientWidth;
if (!w) return;
el.style.setProperty('--sbm-scale', (w / 1150));
// Below ~340 px thumb width the ticker would shrink under legibility
// (matches the full player's `msb-small` breakpoint at 700 px, halved
// for card contexts). Hide the feed on those tiny cards.
el.classList.toggle('sbm-small', w < 340);
}
// ── Live scoring sync ─────────────────────────────────────────
// Each .sbm carries its match state as a data attribute. Bind the
// sibling <video>'s timeupdate to updates on THIS card's scorebar
// only — no globals, no ID collisions with other cards on the page.
function fmtClock(sec) {
sec = Math.max(0, Math.floor(sec || 0));
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
}
function currentRound(rounds, t) {
if (!rounds || !rounds.length) return { n: 1, name: '', start: 0 };
let cur = rounds[0];
for (const r of rounds) { if (t >= (r.start || 0)) cur = r; }
return cur;
}
function lastPoint(points, t) {
if (!points || !points.length) return null;
let last = null;
for (const p of points) { if (p.t <= t + 0.001) last = p; else break; }
return last;
}
// Same label rules as the full player: karate → Yuko/Waza-ari/Ippon,
// taekwondo → Punch/Body kick/Head kick/Turning body, else action text.
function pointLabel(p, sport) {
sport = (sport || '').toLowerCase();
if (sport.startsWith('taekwondo')) {
if (p.pts >= 4) return 'Turning body';
if (p.pts === 3) return 'Head kick';
if (p.pts === 2) return 'Body kick';
if (p.pts === 1) return 'Punch';
}
if (p.pts === 3) return 'Ippon';
if (p.pts === 2) return 'Waza-ari';
if (p.pts === 1) return 'Yuko';
return p.action || 'Point';
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
const feedColor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
function bindLive(sbm) {
let state = null;
try { state = JSON.parse(sbm.getAttribute('data-sbm-state') || 'null'); }
catch (e) { return; }
if (!state) return;
const thumb = sbm.parentElement;
const video = thumb && thumb.querySelector(':scope > video');
if (!video) return;
const redEl = sbm.querySelector('[data-sbm-red-score]');
const blueEl = sbm.querySelector('[data-sbm-blue-score]');
const roundEl = sbm.querySelector('[data-sbm-round]');
const clockEl = sbm.querySelector('[data-sbm-clock]');
const feedEl = sbm.querySelector('[data-sbm-feed-list]');
let lastR = null, lastB = null, lastRoundTxt = null, lastClk = null, lastFeedSig = null;
const tick = () => {
const t = video.currentTime || 0;
const p = lastPoint(state.points, t);
const r = currentRound(state.rounds, t);
const sr = p ? p.sr : 0, sb = p ? p.sb : 0;
if (sr !== lastR) { if (redEl) redEl.textContent = sr; lastR = sr; }
if (sb !== lastB) { if (blueEl) blueEl.textContent = sb; lastB = sb; }
const roundTxt = r.name || ('Round ' + r.n);
if (roundTxt !== lastRoundTxt) { if (roundEl) roundEl.textContent = roundTxt; lastRoundTxt = roundTxt; }
const clk = fmtClock(Math.max(0, t - (r.start || 0)));
if (clk !== lastClk) { if (clockEl) clockEl.textContent = clk; lastClk = clk; }
// Live scoring ticker — newest 3 point events, newest on top.
if (feedEl) {
const seen = (state.points || []).filter(pt => pt.t <= t + 0.001);
const last4 = seen.slice(-4).reverse();
const sig = last4.map(pt => pt.t + ':' + pt.side + ':' + pt.pts).join(',');
if (sig !== lastFeedSig) {
feedEl.innerHTML = last4.map(pt => {
const col = feedColor(pt.side);
return '<div class="sbm-feed-entry" style="border-color:' + col + '">'
+ '<span class="ts">' + fmtClock(pt.t) + '</span>'
+ '<span class="name">' + esc(pointLabel(pt, state.sport)) + '</span>'
+ '<span class="pts" style="color:' + col + '">+' + pt.pts + '</span>'
+ '</div>';
}).join('');
lastFeedSig = sig;
}
}
};
video.addEventListener('timeupdate', tick);
video.addEventListener('seeked', tick);
video.addEventListener('loadedmetadata', tick);
// Reset to 0-0 / 0:00 when the video stops (matches VS mini restart).
video.addEventListener('pause', tick);
video.addEventListener('emptied', tick);
tick();
}
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
for (const e of entries) {
const el = e.target.querySelector(':scope > .sbm');
if (el) fit(el);
}
}) : null;
function register(el) {
fit(el);
if (ro) ro.observe(el.parentElement);
if (!el._sbmBound) { el._sbmBound = true; bindLive(el); }
}
document.querySelectorAll('.sbm').forEach(register);
new MutationObserver(muts => {
for (const m of muts) {
for (const n of m.addedNodes) {
if (!(n instanceof Element)) continue;
if (n.matches?.('.sbm')) register(n);
n.querySelectorAll?.('.sbm').forEach(register);
}
}
}).observe(document.body, { childList: true, subtree: true });
})();
</script>
@endonce

View File

@ -1,604 +0,0 @@
{{-- ══════════════════════════════════════════════════════════════════════
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['stage'] ?? '');
$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;
/* Hidden until JS's fit() has measured the parent and set the real
--vs-mini-scale. Without this, a card whose parent had 0 height at
registration time (grid still resolving on mobile) would paint the
stage at the fallback 0.5 scale larger than the ~640×360 mobile
thumb clipping to just the center (looks like a broken card). */
visibility: hidden;
}
.vs-mini.vs-mini-fit .vs-mini-stage { visibility: visible; }
/* ══ Touch / no-hover devices: skip the orchestrated intro entirely and
render every element in its final state. The intro relies on ~1.3s
of CSS animation-delays; on mobile it was fragile anything that
interrupted timing (late layout resolve, ResizeObserver refires
when the browser toolbar hides/shows, portrait class toggles that
swap `vsmPanelL` `vsmPanelT`) left cards frozen mid-animation.
On mobile users are scrolling, not hovering to watch a preview,
so the intro adds no value show the final composition instead. ══ */
/* Same "no-animation, final-state" treatment when the parent explicitly
requests it used by the OG-image render route so Browsershot
screenshots the composed final frame regardless of what Chromium
thinks its hover capability is. */
.vs-mini-static,
.vs-mini-static .vs-mini-panel,
.vs-mini-static .vs-mini-info,
.vs-mini-static .vs-mini-top,
.vs-mini-static .vs-mini-center,
.vs-mini-static .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini-static .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini-static .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini-static .vs-mini-center { transform: translate(-50%, -52%) !important; }
.vs-mini-static .vs-mini-photo,
.vs-mini-static .vs-mini-word,
.vs-mini-static .vs-mini-shine { animation: none !important; }
@media (hover: none) {
.vs-mini,
.vs-mini .vs-mini-panel,
.vs-mini .vs-mini-info,
.vs-mini .vs-mini-top,
.vs-mini .vs-mini-center,
.vs-mini .vs-mini-bottom {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.vs-mini .vs-mini-panel-red { transform: translateX(0) !important; }
.vs-mini .vs-mini-panel-blue { transform: translateX(0) !important; }
.vs-mini .vs-mini-top { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-bottom { transform: translateX(-50%) !important; }
.vs-mini .vs-mini-center { transform: translate(-50%, -52%) !important; }
/* Also skip the ambient photo drift + VS pulse/shine they were
running fine, but there's no need to burn cycles on animation
loops for a scrolling grid on mobile. */
.vs-mini .vs-mini-photo,
.vs-mini .vs-mini-word,
.vs-mini .vs-mini-shine { animation: none !important; }
}
/* 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));
// Reveal the stage — hidden by default so the CSS's fallback
// scale never paints a pre-fit oversized/clipped stage.
el.classList.add('vs-mini-fit');
}
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. Only bind on real hover devices — on touch,
// Chrome synthesizes mouseleave on tap-lift and during scroll, which
// kept restarting animations and leaving cards frozen mid-intro
// (elements at opacity:0 during the 0.51.3s animation-delay window).
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
if (hasHover) {
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');
void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
}
})();
</script>
@endonce

View File

@ -41,38 +41,6 @@ $langFlag = $forceTrackFlag ?: ($video ? Languages::flag($video->language) : nul
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#'; $showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
// Composed display title for match videos — matches the header on the
// match page: "{match title} {blue flag+name} vs {red flag+name}
// ({weight})", with blue/red corner colours and inline flags. Falls
// back to plain $video->title for non-match cards.
$displayTitle = $video?->title;
$displayTitleHtml = null;
if ($video && $video->type === 'match' && $video->sportsMatch) {
$hdCard = $video->sportsMatch->headerData();
$blueN = $hdCard['blue']['name'] ?? null;
$redN = $hdCard['red']['name'] ?? null;
$blueF = $hdCard['blue']['flag'] ?? null;
$redF = $hdCard['red']['flag'] ?? null;
if ($blueN || $redN) {
$flagStyle = 'width:16px;height:12px;border-radius:2px;display:inline-block;vertical-align:middle;margin-right:4px;';
$mkSide = function ($name, $flag, $color) use ($flagStyle) {
if (!$name) return '';
$flagHtml = $flag ? '<span class="fi fi-'.e($flag).'" style="'.$flagStyle.'"></span>' : '';
return '<span style="color:'.$color.';font-weight:600;">'.$flagHtml.e($name).'</span>';
};
$blueHtml = $mkSide($blueN, $blueF, '#2563eb');
$redHtml = $mkSide($redN, $redF, '#ef4444');
$parts = [];
if (!empty($video->title)) $parts[] = e(strtoupper($video->title)) . ' ';
if ($blueHtml && $redHtml) $parts[] = $blueHtml . ' <span>vs</span> ' . $redHtml;
elseif ($blueHtml) $parts[] = $blueHtml;
elseif ($redHtml) $parts[] = $redHtml;
if (!empty($hdCard['weight_category'])) $parts[] = '(' . e($hdCard['weight_category']) . ')';
$displayTitleHtml = implode(' ', $parts);
}
}
// Size classes // Size classes
$sizeClasses = match($size) { $sizeClasses = match($size) {
'small' => 'yt-video-card-sm', 'small' => 'yt-video-card-sm',
@ -86,21 +54,11 @@ $sizeClasses = match($size) {
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)" <div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}"> data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')"> <img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.vs-mini', ['video' => $video])
@endif
@if($videoUrl) @if($videoUrl)
{{-- Match cards preload metadata so the first frame is ready the <video preload="none">
instant the user hovers (otherwise the browser fetches, buffers
then paints showing a black frame behind the fade). Other
card types keep preload="none" to save bandwidth. --}}
<video preload="{{ $video && $video->type === 'match' ? 'metadata' : 'none' }}" playsinline>
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}"> <source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
</video> </video>
@endif @endif
@if($video && $video->type === 'match' && $video->sportsMatch)
@include('components.partials.scorebar-mini', ['video' => $video])
@endif
{{-- Equalizer overlay shown when audio-only track is previewing --}} {{-- Equalizer overlay shown when audio-only track is previewing --}}
<div class="audio-preview-overlay"> <div class="audio-preview-overlay">
<div class="audio-eq"> <div class="audio-eq">
@ -145,11 +103,7 @@ $sizeClasses = match($size) {
@if($langFlag) @if($langFlag)
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span> <span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
@endif @endif
@if($displayTitleHtml) {{ $video->title ?? 'Untitled Video' }}
{!! $displayTitleHtml !!}
@else
{{ $displayTitle ?? 'Untitled Video' }}
@endif
</a> </a>
</h3> </h3>
@if($video && $video->user) @if($video && $video->user)

View File

@ -220,31 +220,23 @@
background: #000; background: #000;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
/* Always 16:9 — no max-height so the shape is never distorted. */ /* default aspect ratio; overridden per orientation */
aspect-ratio: 16/9; aspect-ratio: 16/9;
max-height: 70vh;
/* Establish a size container so descendants can size themselves relative /* Establish a size container so descendants can size themselves relative
to the player width (used by the coach-note overlay to scale). */ to the player width (used by the coach-note overlay to scale). */
container-type: inline-size; container-type: inline-size;
container-name: ytpwrap; container-name: ytpwrap;
} }
/* Orientation classes intentionally forced to 16:9 too, so the frame .ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
shape never changes with the underlying media orientation. The video .ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
element itself uses object-fit: contain and will letterbox as needed. */ .ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
.ytp-wrap.portrait,
.ytp-wrap.square,
.ytp-wrap.ultrawide {
aspect-ratio: 16/9;
width: 100%;
max-width: none;
max-height: none;
margin: 0;
}
/* Theater mode — keep 16:9 shape, just remove rounded corners. */ /* Theater mode */
.ytp-wrap.theater { .ytp-wrap.theater {
aspect-ratio: 16/9; max-height: 80vh;
height: auto; aspect-ratio: unset;
max-height: none; height: 80vh;
border-radius: 0; border-radius: 0;
} }
@ -542,23 +534,10 @@
background: rgba(28,28,28,.95); background: rgba(28,28,28,.95);
border-radius: 12px; border-radius: 12px;
min-width: 200px; min-width: 200px;
/* Cap height so the panel stays inside the player and scrolls when the overflow: hidden;
item list (mini/speed/autoplay/shuffle + injected Scoreboard toggles)
grows taller than the available space above the control bar. */
max-height: calc(100vh - 120px);
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
box-shadow: 0 4px 24px rgba(0,0,0,.6); box-shadow: 0 4px 24px rgba(0,0,0,.6);
z-index: 100; z-index: 100;
} }
/* Slim, on-brand scrollbar so it doesn't clash with the dark panel. */
.ytp-settings-panel::-webkit-scrollbar { width: 6px; }
.ytp-settings-panel::-webkit-scrollbar-thumb {
background: rgba(255,255,255,.22); border-radius: 3px;
}
.ytp-settings-panel::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.35); }
.ytp-settings-panel::-webkit-scrollbar-track { background: transparent; }
.ytp-settings-panel.open { display: block; } .ytp-settings-panel.open { display: block; }
.ytp-settings-item { .ytp-settings-item {
display: flex; display: flex;
@ -612,10 +591,10 @@
/* ── Mobile ── */ /* ── Mobile ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.ytp-wrap { border-radius: 0; margin: 0; width: 100%; aspect-ratio: 16/9; max-height: none; } .ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; }
.ytp-wrap.portrait, .ytp-wrap.portrait { max-height: 75vh; width: auto; max-width: 100%; }
.ytp-wrap.square, .ytp-wrap.square { max-height: 85vw; max-width: 85vw; }
.ytp-wrap.ultrawide { aspect-ratio: 16/9; width: 100%; max-width: none; max-height: none; } .ytp-wrap.ultrawide { max-height: 50vw; }
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; } .video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
} }

View File

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

View File

@ -7,28 +7,18 @@
/* ── Regular video grid ── */ /* ── Regular video grid ── */
.yt-video-grid { .yt-video-grid {
display: grid; display: grid;
/* minmax(0, 1fr) keeps every column exactly equal in width. grid-template-columns: repeat(3, 1fr);
Plain 1fr lets a track grow if a child's min-content is wider
than the share, which was making the 3rd column wider (and
therefore taller, since the thumb is 16/9) than the first two. */
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 1fr;
align-items: stretch;
gap: 20px; gap: 20px;
} }
.yt-video-grid > .yt-video-card { height: 100%; min-width: 0; } @media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: minmax(0, 1fr); gap: 14px; } }
/* ── Thumbnail orientation fix ── */ /* ── Thumbnail orientation fix ── */
/* ── Thumbnail orientation fix ── /* ── Thumbnail orientation fix ── */
Always cover so every thumbnail visually fills its 16/9 box the .yt-video-card .yt-video-thumb img { object-fit: cover; }
same way. `!important` beats any leftover inline style from the
old adjust-fit script (cached page in the wild). */
.yt-video-card .yt-video-thumb img { object-fit: cover !important; }
/* ── Search result helpers ── */ /* ── Search result helpers ── */
.search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; } .search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; }
@ -217,11 +207,18 @@
@section('scripts') @section('scripts')
<script> <script>
/* Thumbnails render at a uniform size: the .yt-video-thumb box is a (function () {
strict 16/9 container and every image inside it uses `object-fit: function adjustPlThumb(img) {
cover` (set in the shared card CSS) so portrait/square art fills the img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover';
frame instead of shrinking with letterboxing. No per-image adjustment }
needed. */ document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
if (img.complete && img.naturalWidth) {
adjustPlThumb(img);
} else {
img.addEventListener('load', function () { adjustPlThumb(img); });
}
});
})();
/* ─── Filter-bar horizontal scroll ─── */ /* ─── Filter-bar horizontal scroll ─── */
(function () { (function () {
@ -304,9 +301,13 @@
}); });
} }
// No per-image sizing needed — CSS `object-fit: cover` on // Re-run any grid-scoped init that the page's other IIFE did on first load.
// `.yt-video-thumb img` gives every card the same visible image size. function reinitGrid(root) {
function reinitGrid(_root) {} root.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
function fit() { img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover'; }
if (img.complete && img.naturalWidth) fit(); else img.addEventListener('load', fit);
});
}
var currentReq = 0; var currentReq = 0;
async function swapTo(url, pushHist) { async function swapTo(url, pushHist) {

View File

@ -268,13 +268,7 @@
// Keeps the score bar, ticker, and identity at their pixel-perfect // Keeps the score bar, ticker, and identity at their pixel-perfect
// proportions no matter how wide the actual player is. // proportions no matter how wide the actual player is.
function initScale() { function initScale() {
// Scale against #videoContainer (the .ytp inner box) — that's the const wrap = document.getElementById('ytpWrap');
// element that gets squeezed when the fullscreen highlights drawer
// opens (CSS: `.ytp-wrap.ytp-fullscreen.hl-drawer-open .ytp { width:
// calc(100% - var(--hl-drawer-w)) }`). Using #ytpWrap here would
// keep the overlay at full-screen width and clip its right edge
// behind the drawer.
const wrap = document.getElementById('videoContainer') || document.getElementById('ytpWrap');
const layer = document.getElementById('msbScale'); const layer = document.getElementById('msbScale');
if (!wrap || !layer) return; if (!wrap || !layer) return;
const DESIGN_W = 1150; const DESIGN_W = 1150;
@ -284,17 +278,10 @@
const s = w / DESIGN_W; const s = w / DESIGN_W;
layer.style.transform = 'scale(' + s + ')'; layer.style.transform = 'scale(' + s + ')';
layer.style.height = (h / s) + 'px'; layer.style.height = (h / s) + 'px';
// "small player" auto-hides identity + feed. Skip it in // Toggle the "small player" state once we're below the design
// fullscreen — the highlights drawer squeezes the container // brief's ~700 px breakpoint (in real pixels).
// width but the user still wants to see the live feed next document.body.classList.toggle('msb-small', w < 700);
// to the drawer.
const inFs = !!(document.fullscreenElement || document.webkitFullscreenElement);
document.body.classList.toggle('msb-small', !inFs && w < 700);
}; };
// Re-run when entering/exiting fullscreen so the msb-small flag
// updates immediately.
document.addEventListener('fullscreenchange', update);
document.addEventListener('webkitfullscreenchange', update);
update(); update();
if (typeof ResizeObserver === 'function') { if (typeof ResizeObserver === 'function') {
new ResizeObserver(update).observe(wrap); new ResizeObserver(update).observe(wrap);

View File

@ -40,9 +40,20 @@
/* transform + height set by JS */ /* transform + height set by JS */
} }
/* Scrim disabled every scoreboard text element has its own text-shadow /* Scrims for legibility */
for legibility, so we don't tint the video at all. */ .msb-scrim {
.msb-scrim { display: none; } position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(to top, rgba(6,6,8,.9) 0%, rgba(6,6,8,.45) 15%, rgba(6,6,8,0) 30%),
linear-gradient(to bottom, rgba(6,6,8,.85) 0%, rgba(6,6,8,.4) 14%, rgba(6,6,8,0) 30%);
opacity: 0;
transition: opacity .3s ease;
}
/* Only show the scrims when there is at least one overlay part visible */
.msb-has-scorebar .msb-scrim,
.msb-has-feed .msb-scrim,
.msb-has-info .msb-scrim,
.msb-has-timeline .msb-scrim { opacity: 1; }
/* ────────────────────────────── match info (top-left) ────────────────── */ /* ────────────────────────────── match info (top-left) ────────────────── */
.msb-info { position: absolute; top: 22px; left: 26px; display: flex; align-items: stretch; gap: 12px; } .msb-info { position: absolute; top: 22px; left: 26px; display: flex; align-items: stretch; gap: 12px; }

View File

@ -1,80 +0,0 @@
{{-- ══════════════════════════════════════════════════════════════════════
VS OG 1200×630 standalone page consumed by Browsershot for the
social-share preview image. Renders the exact same .vs-mini
composition used on video cards, but at fixed 1200×630 with
animations disabled (via .vs-mini-static) so the screenshot always
captures the final composed frame.
Public but noindex only invoked by the internal ogImage()
controller through a loopback HTTP request from Browsershot.
════════════════════════════════════════════════════════════════════════ --}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OG Preview</title>
<meta name="robots" content="noindex,nofollow">
<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">
{{-- Force http:// on the flag CSS URL Laravel's asset() caches the
scheme on first use (https from AppServiceProvider::forceScheme) and
URL::forceScheme('http') in the controller can't override that
cache. Chromium hits this endpoint via loopback where https isn't
reachable, so we build the URL ourselves. --}}
<link rel="stylesheet" href="http://{{ request()->getHost() }}/vendor/flag-icons/css/flag-icons.min.css">
<style>
/* Canvas is a true 16:9 at 1200×675 so the 1920×1080 vs-mini stage
maps cleanly with scale 0.625 (no overflow on either axis). WhatsApp/
Facebook accept any aspect ratio for og:image; 1200×675 is inside
their recommended range and preserves the full VS composition. */
html, body {
margin: 0; padding: 0;
width: 1200px; height: 675px;
background: #050507;
overflow: hidden;
}
.og-frame {
position: relative;
width: 1200px; height: 675px;
overflow: hidden;
}
/* Same wrapper the vs-mini expects .yt-video-thumb is what the mini's
`position: absolute; inset: 0` anchors to, and its aspect drives fit(). */
.og-frame .yt-video-thumb {
position: relative;
width: 100%; height: 100%;
overflow: hidden;
background: #050507;
}
</style>
</head>
<body>
<div class="og-frame">
<div class="yt-video-thumb">
{{-- vs-mini reads $video->sportsMatch->headerData() to compose. --}}
@include('components.partials.vs-mini', ['video' => $video])
</div>
</div>
<script>
// Force the mini into its final composed state — no animation-delay
// window, no ResizeObserver races. We also inline the correct scale for
// a 1200×630 frame (1200/1920 = 0.625) so we don't depend on the fit()
// callback firing before Browsershot captures.
(function () {
const el = document.querySelector('.vs-mini');
if (!el) return;
el.classList.add('vs-mini-static', 'vs-mini-fit');
el.style.setProperty('--vs-mini-scale', String(1200 / 1920));
// Signal readiness for Browsershot's waitUntilNetworkIdle to observe
// once webfonts are done rendering.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => { window.__vsOgReady = true; });
} else {
window.__vsOgReady = true;
}
})();
</script>
</body>
</html>

View File

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

View File

@ -366,29 +366,6 @@
} }
} }
/* ── Narrow desktop: too little room for a side rail ──────────────
Between 992px and 1300px the left nav (240px) and the 300px rail
leave the video column under ~700px, so the Up Next titles collapse
into a ~124px channel. Trigger the SAME stacking the ≤991px layout
already does, just earlier. Nothing is removed the ≤991px block
still owns the phone treatment (thumb-on-top cards). */
@media (max-width: 1300px) {
.video-layout-container {
flex-direction: column !important;
}
.yt-video-section {
width: 100% !important;
flex: none !important;
}
.yt-sidebar-container {
width: 100% !important;
margin-top: 16px;
}
}
@media (max-width: 991px) { @media (max-width: 991px) {
.yt-main { .yt-main {
margin-left: 0; margin-left: 0;

View File

@ -3,87 +3,18 @@
@section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name')) @section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name'))
@push('head') @push('head')
@php
// ── Build a rich share-preview description from the match record ──
// Layout the user requested:
// Abdulla Bassam vs Zakaria Shuaiter
// National Team Selection Trials · Match# 13 · Court 1 · Anoj K.R - Mahadwa K
// 37 views · Aug 09, 2026
// WhatsApp collapses newlines to spaces but respects `\n`; other
// platforms (Facebook, Telegram, Slack) render them as line breaks.
$hd = $video->sportsMatch?->headerData() ?? [];
$redName = trim((string) ($hd['red']['name'] ?? ''));
$blueName = trim((string) ($hd['blue']['name'] ?? ''));
$redFlag = strtoupper(trim((string) ($hd['red']['flag'] ?? '')));
$blueFlag = strtoupper(trim((string) ($hd['blue']['flag'] ?? '')));
$event = trim((string) ($hd['championship'] ?? ''));
$matchNo = trim((string) ($hd['match_number'] ?? ''));
$court = trim((string) ($hd['court'] ?? ''));
$referee = trim((string) ($hd['referee']['name'] ?? ''));
$weight = trim((string) ($hd['weight_category'] ?? ''));
// ISO2 country code → Unicode regional-indicator emoji flag (e.g. "BH" → 🇧🇭).
// Emoji flags are the only way to embed a flag in a text-only <meta>
// field. They render on WhatsApp, Telegram, iMessage, and mobile
// Facebook — the platforms people actually preview shared links in.
// (The self-hosted flag-icons library used elsewhere in the app is
// for HTML; meta descriptions can't carry markup.)
$flagEmoji = function (string $c): string {
if (! preg_match('/^[A-Z]{2}$/', $c)) return '';
return mb_chr(0x1F1E6 + (ord($c[0]) - 65), 'UTF-8')
. mb_chr(0x1F1E6 + (ord($c[1]) - 65), 'UTF-8');
};
$blueTag = trim(($flagEmoji($blueFlag) . ' ' . $blueName));
$redTag = trim(($flagEmoji($redFlag) . ' ' . $redName));
// Line 1 — VS matchup (blue vs red, matching the on-card layout).
$ogLineVs = '';
if ($blueTag !== '' && $redTag !== '') {
$ogLineVs = $blueTag . ' vs ' . $redTag;
} elseif ($blueTag !== '' || $redTag !== '') {
$ogLineVs = $blueTag ?: $redTag;
}
// Line 2 — event context, joined with " · " so it reads well even
// when a platform strips the newlines.
$ogParts = array_values(array_filter([
$event,
$weight,
$matchNo !== '' ? 'Match# ' . $matchNo : '',
$court !== '' ? 'Court ' . $court : '',
$referee,
], fn ($s) => $s !== ''));
$ogLineCtx = implode(' · ', $ogParts);
// Line 3 — engagement + date, mirroring the video meta row.
$viewsLbl = number_format((int) ($video->view_count ?? 0)) . ' views';
$dateLbl = optional($video->created_at)->format('M d, Y');
$ogLineMeta = trim($viewsLbl . ($dateLbl ? ' · ' . $dateLbl : ''));
$ogDescription = trim(implode("\n", array_filter([$ogLineVs, $ogLineCtx, $ogLineMeta])));
// Fall back to the raw description if the match record is empty.
if ($ogDescription === '') {
$ogDescription = Str::limit(strip_tags($video->description ?? (config('app.name') . ' — watch now')), 200);
}
// Append a version query so WhatsApp/Facebook recache when the match
// (fighters, flags, event, weight) is edited. The ogImage controller
// internally keys its file cache by the same stamp.
$ogStamp = optional($video->sportsMatch?->updated_at ?? $video->updated_at)->timestamp;
$ogUrl = route('videos.ogImage', $video) . ($ogStamp ? '?v=' . $ogStamp : '');
@endphp
<meta property="og:title" content="{{ $video->title }}"> <meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ $ogDescription }}"> <meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
<meta property="og:image" content="{{ $ogUrl }}"> <meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
<meta property="og:image:width" content="1200"> <meta property="og:image:width" content="1200">
<meta property="og:image:height" content="675"> <meta property="og:image:height" content="630">
<meta property="og:url" content="{{ $video->share_url }}"> <meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}"> <meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $video->title }}"> <meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ $ogDescription }}"> <meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
<meta name="twitter:image" content="{{ $ogUrl }}"> <meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
@endpush @endpush
@section('extra_styles') @section('extra_styles')
@ -2019,43 +1950,6 @@
} }
} }
/* ── Narrow desktop: too little room for a side rail ──────────────
Between 992px and 1300px the left nav (240px) and the 300px rail
leave the video column under ~700px: Up Next titles collapse to a
~124px channel and the match header/fighters grid break apart.
Trigger the SAME stacking the ≤991px layout already does, just
earlier. Nothing is removed the ≤991px block still owns the
phone treatment (thumb-on-top cards, review view, bottom sheet). */
@media (max-width: 1300px) {
.video-layout-container {
flex-direction: column !important;
}
.yt-video-section {
width: 100% !important;
flex: none !important;
}
.yt-sidebar-container,
.events-sidebar {
width: 100% !important;
}
.yt-sidebar-container {
margin-top: 16px;
}
.fighters-row {
grid-template-columns: 1fr !important;
}
.event-header {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 991px) { @media (max-width: 991px) {
.yt-main { .yt-main {
margin-left: 0; margin-left: 0;
@ -2336,10 +2230,8 @@
<span class="replay-badge-word">REPLAY</span> <span class="replay-badge-word">REPLAY</span>
<span class="replay-badge-speed" id="replayBadgeSpeed">×1</span> <span class="replay-badge-speed" id="replayBadgeSpeed">×1</span>
</div> </div>
{{-- Karate/Taekwondo scoreboard overlay + gear-menu toggles --}} {{-- Karate/Taekwondo scoreboard overlay + VS intro + gear-menu toggles --}}
@include('videos.partials.match.scoreboard.index') @include('videos.partials.match.scoreboard.index')
{{-- VS intro card first-play arena screen (skippable) --}}
@include('videos.partials.match.vs.index')
</x-slot:overlay> </x-slot:overlay>
</x-video-player> </x-video-player>
@ -2744,6 +2636,13 @@
<div class="tab-header"> <div class="tab-header">
<button class="tab-button active" data-tab="official">Points</button> <button class="tab-button active" data-tab="official">Points</button>
<button class="tab-button" data-tab="review">Coach review</button> <button class="tab-button" data-tab="review">Coach review</button>
{{-- Slow-mo speed selector affects both point replays and coach note replays --}}
<div class="slowmo-picker" role="group" aria-label="Slow-mo replay speed" title="Slow-mo speed">
<span class="slowmo-picker-lbl"><i class="bi bi-hourglass-split"></i></span>
<button type="button" class="slowmo-opt" data-slowmo="0.25">¼×</button>
<button type="button" class="slowmo-opt is-active" data-slowmo="0.5">½×</button>
<button type="button" class="slowmo-opt" data-slowmo="0.75">¾×</button>
</div>
</div> </div>
<div class="tab-panels"> <div class="tab-panels">
<!-- Points Tab --> <!-- Points Tab -->
@ -4793,9 +4692,6 @@
timeInp.value = toMinuteSecondClock(video.currentTime || 0); timeInp.value = toMinuteSecondClock(video.currentTime || 0);
return; return;
} }
// Typing a timestamp is a scrubber interaction — freeze the
// frame. Only the play button resumes playback.
forcePause();
const dur = Number(video.duration) || 0; const dur = Number(video.duration) || 0;
const clamped = Math.max(0, dur > 0 ? Math.min(dur, secs) : secs); const clamped = Math.max(0, dur > 0 ? Math.min(dur, secs) : secs);
try { video.currentTime = clamped; } catch (_) {} try { video.currentTime = clamped; } catch (_) {}
@ -4848,10 +4744,6 @@
_pcbCtx = { roundNumber, roundId, video, wrap, bar, slider, _pcbCtx = { roundNumber, roundId, video, wrap, bar, slider,
onSlide, onSlideCommit, onVideoTime, onKey, onSliderGrab, onSlide, onSlideCommit, onVideoTime, onKey, onSliderGrab,
timeInp, commitTimeInput, onTimeInputKey, timeInp, commitTimeInput, onTimeInputKey,
// Exposed so pcbNudge / commitTimeInput (defined
// outside this closure) can guarantee the video
// stays paused during any scrubber interaction.
forcePause,
existingId: existingSingle ? existingSingle.id : null, existingId: existingSingle ? existingSingle.id : null,
existingBlueId: existingBlue ? existingBlue.id : null, existingBlueId: existingBlue ? existingBlue.id : null,
existingRedId: existingRed ? existingRed.id : null }; existingRedId: existingRed ? existingRed.id : null };
@ -5035,11 +4927,7 @@
} }
function pcbNudge(deltaSeconds) { function pcbNudge(deltaSeconds) {
if (!_pcbCtx) return; if (!_pcbCtx) return;
const { video, slider, forcePause } = _pcbCtx; const { video, slider } = _pcbCtx;
// Any scrubber interaction must freeze the frame — the user is
// trying to land on a precise moment. Playback resumes only when
// they explicitly press the play button.
if (forcePause) forcePause();
// Direction only — nudge is always ±1 frame, at every zoom level // Direction only — nudge is always ±1 frame, at every zoom level
const dir = deltaSeconds >= 0 ? 1 : -1; const dir = deltaSeconds >= 0 ? 1 : -1;
const step = FRAME_STEP() * dir; const step = FRAME_STEP() * dir;
@ -5618,10 +5506,6 @@
sliderS, sliderE, fillEl, sliderS, sliderE, fillEl,
onSlideStart, onSlideEnd, onSlideCommit, onVideoTime, onKey, onSlideStart, onSlideEnd, onSlideCommit, onVideoTime, onKey,
onSliderGrabR, onSliderGrabR,
// Exposed for rcbNudge / commitStart / commitEnd so
// every scrubber interaction freezes the frame — only
// an explicit play press should resume playback.
forcePause: forcePauseR,
startInp, endInp, commitStart, commitEnd, startInp, endInp, commitStart, commitEnd,
noteInp, previewFn, overlay, noteInp, previewFn, overlay,
// Seed from existing position when editing, else default // Seed from existing position when editing, else default
@ -5738,9 +5622,6 @@
const val = document.getElementById(which === 'end' ? 'rcbEnd' : 'rcbStart').value; const val = document.getElementById(which === 'end' ? 'rcbEnd' : 'rcbStart').value;
const secs = parsePcbTimeInput(val); const secs = parsePcbTimeInput(val);
if (secs !== null && _rcbCtx && _rcbCtx.video) { if (secs !== null && _rcbCtx && _rcbCtx.video) {
// Switching the active slot re-scrubs the video — pause it
// so the user doesn't unexpectedly resume playback.
if (_rcbCtx.forcePause) _rcbCtx.forcePause();
try { _rcbCtx.video.currentTime = secs; } catch (_) {} try { _rcbCtx.video.currentTime = secs; } catch (_) {}
// Keep dual sliders in sync with the input value // Keep dual sliders in sync with the input value
if (_rcbCtx.sliderS && which === 'start') _rcbCtx.sliderS.value = secs; if (_rcbCtx.sliderS && which === 'start') _rcbCtx.sliderS.value = secs;
@ -5766,9 +5647,6 @@
inp.value = toMinuteSecondClock(_rcbCtx.video.currentTime || 0); inp.value = toMinuteSecondClock(_rcbCtx.video.currentTime || 0);
return; return;
} }
// Typing a timestamp is a scrubber interaction — freeze the
// frame. Playback resumes only on an explicit play press.
if (_rcbCtx.forcePause) _rcbCtx.forcePause();
const dur = Number(_rcbCtx.video.duration) || 0; const dur = Number(_rcbCtx.video.duration) || 0;
const clamped = Math.max(0, dur > 0 ? Math.min(dur, secs) : secs); const clamped = Math.max(0, dur > 0 ? Math.min(dur, secs) : secs);
inp.value = toMinuteSecondClock(clamped); inp.value = toMinuteSecondClock(clamped);
@ -5845,10 +5723,7 @@
// Nudge the LAST-TOUCHED scrubber (start or end) by ±1 frame. // Nudge the LAST-TOUCHED scrubber (start or end) by ±1 frame.
function rcbNudge(deltaSeconds) { function rcbNudge(deltaSeconds) {
if (!_rcbCtx) return; if (!_rcbCtx) return;
const { video, sliderS, sliderE, startInp, endInp, forcePause } = _rcbCtx; const { video, sliderS, sliderE, startInp, endInp } = _rcbCtx;
// Freeze the frame — scrubber steps are for precise landing,
// not incidental resume-playback triggers.
if (forcePause) forcePause();
const which = (typeof _rcbCtxLastTouched === 'function' ? _rcbCtxLastTouched() : 'start'); const which = (typeof _rcbCtxLastTouched === 'function' ? _rcbCtxLastTouched() : 'start');
const target = which === 'end' ? sliderE : sliderS; const target = which === 'end' ? sliderE : sliderS;
const otherVal = Number(which === 'end' ? sliderS.value : sliderE.value) || 0; const otherVal = Number(which === 'end' ? sliderS.value : sliderE.value) || 0;

View File

@ -356,29 +356,6 @@
} }
} }
/* ── Narrow desktop: too little room for a side rail ──────────────
Between 992px and 1300px the left nav (240px) and the 300px rail
leave the video column under ~700px, so the Up Next titles collapse
into a ~124px channel. Trigger the SAME stacking the ≤991px layout
already does, just earlier. Nothing is removed the ≤991px block
still owns the phone treatment (thumb-on-top cards). */
@media (max-width: 1300px) {
.video-layout-container {
flex-direction: column !important;
}
.yt-video-section {
width: 100% !important;
flex: none !important;
}
.yt-sidebar-container {
width: 100% !important;
margin-top: 16px;
}
}
@media (max-width: 991px) { @media (max-width: 991px) {
.yt-main { .yt-main {
margin-left: 0; margin-left: 0;

View File

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

View File

@ -40,10 +40,6 @@ Route::get('/videos/{numericId}', function ($numericId) {
Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken'); Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken');
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show'); Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
Route::get('/videos/{video}/vs-preview', function (\App\Models\Video $video) {
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
return view('videos.vs-preview', ['video' => $video]);
})->name('videos.vsPreview');
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream'); Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
Route::get('/videos/{video}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track'); Route::get('/videos/{video}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track');
Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls'); Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls');
@ -61,35 +57,6 @@ Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->n
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']); Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']); Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage'); Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
// vs-og-frame is the Browsershot-only, no-animation, 1200×630 render used by
// the OG image generator. Distinct from /vs-preview (line 43) which is a
// full-page interactive preview of the arena intro overlay.
Route::get('/videos/{video}/vs-og-frame', [VideoController::class, 'vsPreview'])->name('videos.vsOgFrame');
// Admin-only gallery of all generated OG preview PNGs (the composed VS
// cards that WhatsApp/Facebook see when a match video URL is shared).
Route::get('/admin/og-previews', function () {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
$files = collect(glob(storage_path('app/og-cache/match-*.{jpg,png}'), GLOB_BRACE) ?: [])
->map(fn ($f) => [
'name' => basename($f),
'size' => filesize($f),
'mtime' => filemtime($f),
'id' => (int) (explode('-', pathinfo($f, PATHINFO_FILENAME))[1] ?? 0),
])
->sortByDesc('mtime')
->values();
return view('admin.og-previews', ['files' => $files]);
})->name('admin.og-previews');
Route::get('/admin/og-previews/{name}', function (string $name) {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
abort_unless(preg_match('/^match-\d+-\d+\.(jpg|png)$/', $name), 404);
$path = storage_path('app/og-cache/' . $name);
abort_unless(is_file($path), 404);
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return response()->file($path, ['Content-Type' => $ext === 'png' ? 'image/png' : 'image/jpeg']);
})->name('admin.og-previews.file');
Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access'); Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access');
Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth'); Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');
Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth'); Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth');