Compare commits
13 Commits
coach-revi
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c8fdcdf3 | ||
|
|
9f82d3cf31 | ||
|
|
3e18bd2150 | ||
|
|
665bad76bf | ||
|
|
04d63cccdb | ||
|
|
5b6bd86d70 | ||
|
|
b05ff14b3b | ||
|
|
5d038488fb | ||
|
|
2b5e480c9a | ||
|
|
19741f489b | ||
|
|
ed921a4ccf | ||
|
|
1c4e4986b5 | ||
|
|
55247828e1 |
47
app/Console/Commands/RenderMatchOgImage.php
Normal file
47
app/Console/Commands/RenderMatchOgImage.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,18 @@ class Countries
|
|||||||
* All countries keyed by ISO2 code.
|
* All countries keyed by ISO2 code.
|
||||||
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
|
* Fields: name, iso2, iso3, flag, dial_code, timezone, currency
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Resolve any ISO2 code (case-insensitive) to its full country name.
|
||||||
|
* Falls back to the uppercased code, then to null for empty input.
|
||||||
|
* Use this instead of printing raw two-letter codes to end users.
|
||||||
|
*/
|
||||||
|
public static function name(?string $iso2): ?string
|
||||||
|
{
|
||||||
|
$code = strtoupper(trim((string) $iso2));
|
||||||
|
if ($code === '') return null;
|
||||||
|
return self::all()[$code]['name'] ?? $code;
|
||||||
|
}
|
||||||
|
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
// Return lowercase ISO2 code — used as the fi fi-{code} CSS class (flag-icons library)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ class MatchEventController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'round_number' => 'required|integer|min:1',
|
'round_number' => 'required|integer|min:1',
|
||||||
'name' => 'nullable|string|max:50',
|
'name' => 'nullable|string|max:50',
|
||||||
'start_time_seconds' => 'nullable|integer|min:0',
|
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if user owns the video
|
// Check if user owns the video
|
||||||
@ -45,7 +45,7 @@ class MatchEventController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'round_number' => 'sometimes|integer|min:1',
|
'round_number' => 'sometimes|integer|min:1',
|
||||||
'name' => 'required|string|max:50',
|
'name' => 'required|string|max:50',
|
||||||
'start_time_seconds' => 'nullable|integer|min:0',
|
'start_time_seconds' => 'nullable|numeric|min:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Check if user owns the video
|
// Check if user owns the video
|
||||||
@ -87,7 +87,7 @@ class MatchEventController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'round_id' => 'required|exists:match_rounds,id',
|
'round_id' => 'required|exists:match_rounds,id',
|
||||||
'timestamp_seconds' => 'required|integer|min:0',
|
'timestamp_seconds' => 'required|numeric|min:0',
|
||||||
'action' => 'required|string|max:255',
|
'action' => 'required|string|max:255',
|
||||||
'points' => 'required|integer|min:1',
|
'points' => 'required|integer|min:1',
|
||||||
'competitor' => 'required|in:blue,red',
|
'competitor' => 'required|in:blue,red',
|
||||||
@ -150,7 +150,7 @@ class MatchEventController extends Controller
|
|||||||
public function updatePoint(Request $request, MatchPoint $point)
|
public function updatePoint(Request $request, MatchPoint $point)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'timestamp_seconds' => 'required|integer|min:0',
|
'timestamp_seconds' => 'required|numeric|min:0',
|
||||||
'action' => 'required|string|max:255',
|
'action' => 'required|string|max:255',
|
||||||
'points' => 'required|integer|min:1',
|
'points' => 'required|integer|min:1',
|
||||||
'competitor' => 'required|in:blue,red',
|
'competitor' => 'required|in:blue,red',
|
||||||
@ -202,8 +202,8 @@ class MatchEventController extends Controller
|
|||||||
public function storeReview(Request $request, Video $video)
|
public function storeReview(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'start_time_seconds' => 'required|integer|min:0',
|
'start_time_seconds' => 'required|numeric|min:0',
|
||||||
'end_time_seconds' => 'nullable|integer|min:0',
|
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||||
'note' => 'required|string|max:1000',
|
'note' => 'required|string|max:1000',
|
||||||
'coach_name' => 'required|string|max:100',
|
'coach_name' => 'required|string|max:100',
|
||||||
'emoji' => 'nullable|string|max:10',
|
'emoji' => 'nullable|string|max:10',
|
||||||
@ -238,8 +238,8 @@ class MatchEventController extends Controller
|
|||||||
public function updateReview(Request $request, CoachReview $review)
|
public function updateReview(Request $request, CoachReview $review)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'start_time_seconds' => 'required|integer|min:0',
|
'start_time_seconds' => 'required|numeric|min:0',
|
||||||
'end_time_seconds' => 'nullable|integer|min:0',
|
'end_time_seconds' => 'nullable|numeric|min:0',
|
||||||
'note' => 'required|string|max:1000',
|
'note' => 'required|string|max:1000',
|
||||||
'coach_name' => 'required|string|max:100',
|
'coach_name' => 'required|string|max:100',
|
||||||
'emoji' => 'nullable|string|max:10',
|
'emoji' => 'nullable|string|max:10',
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -34,6 +35,23 @@ 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 ~3–5s 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.',
|
||||||
@ -51,6 +69,21 @@ 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.',
|
||||||
@ -166,7 +199,9 @@ class SportsMatchController extends Controller
|
|||||||
// Media text fields (caption/alt/credit/public). Preserve existing image
|
// Media text fields (caption/alt/credit/public). Preserve existing image
|
||||||
// paths already on the record; handleImages() overwrites any replaced ones.
|
// paths already on the record; handleImages() overwrites any replaced ones.
|
||||||
$existingMedia = $match->media ?? [];
|
$existingMedia = $match->media ?? [];
|
||||||
$mediaText = $this->clean($request->input('media', []));
|
// clean() returns null when the array is entirely empty — coerce back to []
|
||||||
|
// so array_merge() doesn't blow up on PHP 8's stricter type checking.
|
||||||
|
$mediaText = $this->clean($request->input('media', [])) ?? [];
|
||||||
if (isset($mediaText['public'])) {
|
if (isset($mediaText['public'])) {
|
||||||
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
|
$mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -506,7 +506,7 @@ class SuperAdminController extends Controller
|
|||||||
foreach ($uniqueViewers as $viewer) {
|
foreach ($uniqueViewers as $viewer) {
|
||||||
if (!$viewer->country) continue;
|
if (!$viewer->country) continue;
|
||||||
if (!isset($countryMap[$viewer->country])) {
|
if (!isset($countryMap[$viewer->country])) {
|
||||||
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name, 'total' => 0];
|
$countryMap[$viewer->country] = ['country' => $viewer->country, 'country_name' => $viewer->country_name ?: \App\Data\Countries::name($viewer->country), 'total' => 0];
|
||||||
}
|
}
|
||||||
$countryMap[$viewer->country]['total']++;
|
$countryMap[$viewer->country]['total']++;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
|
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
@ -3126,12 +3126,65 @@ 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();
|
||||||
@ -3383,7 +3436,7 @@ class VideoController extends Controller
|
|||||||
$totalGeo = $rawCountries->sum('cnt');
|
$totalGeo = $rawCountries->sum('cnt');
|
||||||
$countries = $rawCountries->map(fn ($c) => [
|
$countries = $rawCountries->map(fn ($c) => [
|
||||||
'code' => $c->country,
|
'code' => $c->country,
|
||||||
'name' => $c->country_name,
|
'name' => $c->country_name ?: \App\Data\Countries::name($c->country),
|
||||||
'count' => (int) $c->cnt,
|
'count' => (int) $c->cnt,
|
||||||
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
|
'pct' => $totalGeo > 0 ? round($c->cnt / $totalGeo * 100) : 0,
|
||||||
])->values();
|
])->values();
|
||||||
@ -3722,7 +3775,7 @@ class VideoController extends Controller
|
|||||||
: asset('images/default-avatar.svg'))
|
: asset('images/default-avatar.svg'))
|
||||||
: null,
|
: null,
|
||||||
'country' => $topCountry ? $topCountry->country : null,
|
'country' => $topCountry ? $topCountry->country : null,
|
||||||
'country_name' => $topCountry ? $topCountry->country_name : null,
|
'country_name' => $topCountry ? ($topCountry->country_name ?: \App\Data\Countries::name($topCountry->country)) : null,
|
||||||
'reach' => $accesses,
|
'reach' => $accesses,
|
||||||
'created_at' => $s->created_at,
|
'created_at' => $s->created_at,
|
||||||
];
|
];
|
||||||
@ -3868,7 +3921,7 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'country' => $country,
|
'country' => $country,
|
||||||
'country_name' => $countryName ?? $country,
|
'country_name' => $countryName ?: (\App\Data\Countries::name($country) ?? $country),
|
||||||
'total_views' => $totalViews,
|
'total_views' => $totalViews,
|
||||||
'registered_users' => $registeredUsers,
|
'registered_users' => $registeredUsers,
|
||||||
'guest_count' => $guestCount,
|
'guest_count' => $guestCount,
|
||||||
@ -3943,7 +3996,7 @@ class VideoController extends Controller
|
|||||||
->orderByDesc('cnt')
|
->orderByDesc('cnt')
|
||||||
->limit(5)
|
->limit(5)
|
||||||
->get()
|
->get()
|
||||||
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name, 'count' => (int) $c->cnt]);
|
->map(fn ($c) => ['code' => $c->country, 'name' => $c->country_name ?: \App\Data\Countries::name($c->country), 'count' => (int) $c->cnt]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'date' => $day->format('M d, Y'),
|
'date' => $day->format('M d, Y'),
|
||||||
@ -3971,7 +4024,7 @@ class VideoController extends Controller
|
|||||||
->map(fn ($r) => [
|
->map(fn ($r) => [
|
||||||
'type' => $r->type,
|
'type' => $r->type,
|
||||||
'country' => $r->country,
|
'country' => $r->country,
|
||||||
'country_name' => $r->country_name,
|
'country_name' => $r->country_name ?: \App\Data\Countries::name($r->country),
|
||||||
'at' => $r->downloaded_at,
|
'at' => $r->downloaded_at,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -4101,7 +4154,7 @@ class VideoController extends Controller
|
|||||||
foreach ($accesses as $a) {
|
foreach ($accesses as $a) {
|
||||||
$code = $a->country ?: 'XX';
|
$code = $a->country ?: 'XX';
|
||||||
if (! isset($countries[$code])) {
|
if (! isset($countries[$code])) {
|
||||||
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: $code, 'count' => 0];
|
$countries[$code] = ['code' => $code, 'name' => $a->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
|
||||||
}
|
}
|
||||||
$countries[$code]['count']++;
|
$countries[$code]['count']++;
|
||||||
|
|
||||||
@ -4199,7 +4252,7 @@ class VideoController extends Controller
|
|||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$code = $r->country ?: 'XX';
|
$code = $r->country ?: 'XX';
|
||||||
if (! isset($countries[$code])) {
|
if (! isset($countries[$code])) {
|
||||||
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: $code, 'count' => 0];
|
$countries[$code] = ['code' => $code, 'name' => $r->country_name ?: (\App\Data\Countries::name($code) ?? $code), 'count' => 0];
|
||||||
}
|
}
|
||||||
$countries[$code]['count']++;
|
$countries[$code]['count']++;
|
||||||
}
|
}
|
||||||
@ -4241,5 +4294,231 @@ 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 (~3–5s). 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,8 +23,10 @@ class CoachReview extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'position_x' => 'float',
|
'position_x' => 'float',
|
||||||
'position_y' => 'float',
|
'position_y' => 'float',
|
||||||
|
'start_time_seconds' => 'float',
|
||||||
|
'end_time_seconds' => 'float',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function video(): BelongsTo
|
public function video(): BelongsTo
|
||||||
|
|||||||
@ -22,6 +22,10 @@ class MatchPoint extends Model
|
|||||||
'score_red',
|
'score_red',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'timestamp_seconds' => 'float',
|
||||||
|
];
|
||||||
|
|
||||||
public function video(): BelongsTo
|
public function video(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Video::class);
|
return $this->belongsTo(Video::class);
|
||||||
|
|||||||
@ -19,7 +19,7 @@ class MatchRound extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'start_time_seconds' => 'integer',
|
'start_time_seconds' => 'float',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function video(): BelongsTo
|
public function video(): BelongsTo
|
||||||
|
|||||||
@ -44,31 +44,58 @@ class SportsMatch extends Model
|
|||||||
$p = $this->participants ?? [];
|
$p = $this->participants ?? [];
|
||||||
$c = $this->competition ?? [];
|
$c = $this->competition ?? [];
|
||||||
$v = $this->venue ?? [];
|
$v = $this->venue ?? [];
|
||||||
|
$m = $this->media ?? [];
|
||||||
|
$trim = fn($x) => (is_string($x) && trim($x) !== '') ? trim($x) : null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
'sport' => $trim($this->sport),
|
||||||
'blue' => [
|
'blue' => [
|
||||||
'name' => $this->participant1_name ?: null,
|
'name' => $trim($this->participant1_name),
|
||||||
'club' => $p['p1_club'] ?? null,
|
'club' => $trim($p['p1_club'] ?? null),
|
||||||
'flag' => $this->flagFor($p['p1_country'] ?? null),
|
'flag' => $this->flagFor($p['p1_country'] ?? null),
|
||||||
|
// Uploader form (sports-match modal) stores these under the
|
||||||
|
// canonical media.* keys — mirror them to headerData() so the
|
||||||
|
// scoreboard/VS partials get real image paths.
|
||||||
|
'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null),
|
||||||
|
'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null),
|
||||||
],
|
],
|
||||||
'red' => [
|
'red' => [
|
||||||
'name' => $this->participant2_name ?: null,
|
'name' => $trim($this->participant2_name),
|
||||||
'club' => $p['p2_club'] ?? null,
|
'club' => $trim($p['p2_club'] ?? null),
|
||||||
'flag' => $this->flagFor($p['p2_country'] ?? null),
|
'flag' => $this->flagFor($p['p2_country'] ?? null),
|
||||||
|
'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null),
|
||||||
|
'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null),
|
||||||
],
|
],
|
||||||
'weight_category' => $p['weight_class'] ?? null,
|
'weight_category' => $trim($p['weight_class'] ?? null),
|
||||||
'championship' => $c['championship_name'] ?? ($this->event_name ?: null),
|
'division' => $trim($c['division'] ?? null),
|
||||||
'match_number' => $c['match_number'] ?? null,
|
// Sub-header "stage" shown between the two yellow lines above
|
||||||
'court' => $c['court'] ?? null,
|
// 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),
|
||||||
|
'court' => $trim($c['court'] ?? null),
|
||||||
|
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
|
||||||
|
'match_date' => $this->match_date?->format('Y-m-d'),
|
||||||
'referee' => [
|
'referee' => [
|
||||||
'name' => $this->referee_name ?: null,
|
'name' => $trim($this->referee_name),
|
||||||
'flag' => $this->flagFor($p['referee_country'] ?? null),
|
'flag' => $this->flagFor($p['referee_country'] ?? null),
|
||||||
],
|
],
|
||||||
'venue' => [
|
'venue' => [
|
||||||
'name' => $this->venue_name ?: ($v['name'] ?? null),
|
'name' => $trim($this->venue_name) ?? $trim($v['name'] ?? null),
|
||||||
'map_link' => $v['map_link'] ?? null,
|
'map_link' => $trim($v['map_link'] ?? null),
|
||||||
],
|
],
|
||||||
'event_logo' => $this->media['event_poster'] ?? null, // rel path or null
|
'event_logo' => $trim($m['event_poster'] ?? null),
|
||||||
|
/*
|
||||||
|
* Per-video overlay defaults set by the uploader. Shape:
|
||||||
|
* ['scorebar'=>bool, 'feed'=>bool, 'matchInfo'=>bool, 'timeline'=>bool,
|
||||||
|
* 'clubs'=>bool, 'flags'=>bool, 'penalties'=>bool, 'cornerLabels'=>'auto|redblue|karate|taekwondo']
|
||||||
|
* Anything not set falls back to the app defaults in the JS.
|
||||||
|
*/
|
||||||
|
'scoreboard_defaults' => is_array($c['scoreboard'] ?? null) ? $c['scoreboard'] : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
bin/browsershot-node.sh
Executable file
7
bin/browsershot-node.sh
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
#!/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 "$@"
|
||||||
@ -14,7 +14,8 @@
|
|||||||
"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
341
composer.lock
generated
@ -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": "40fa327b55e9b6fafab4b2da3f763724",
|
"content-hash": "bdb64417cd2491642a0712fe40bade22",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@ -1457,6 +1457,90 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@ -2237,6 +2321,71 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@ -4021,6 +4170,196 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Frame-accurate timestamps: the match highlights scrubber lands on
|
||||||
|
* frame boundaries (1/fps seconds). Storing these as INTEGER seconds
|
||||||
|
* throws away sub-second precision, so we promote the columns to
|
||||||
|
* decimal(10,3) — 3 decimals cover up to 1000 fps and centuries of
|
||||||
|
* runtime, and existing integer values migrate losslessly.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// MatchRound.start_time_seconds is used to jump to a round's opening
|
||||||
|
// frame too, so bring it along.
|
||||||
|
Schema::table('match_rounds', function (Blueprint $table) {
|
||||||
|
$table->decimal('start_time_seconds', 10, 3)->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('match_points', function (Blueprint $table) {
|
||||||
|
$table->decimal('timestamp_seconds', 10, 3)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||||
|
$table->decimal('start_time_seconds', 10, 3)->change();
|
||||||
|
$table->decimal('end_time_seconds', 10, 3)->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Round back to integer on rollback — sub-second data would be
|
||||||
|
// truncated but nothing catastrophic happens.
|
||||||
|
Schema::table('match_rounds', function (Blueprint $table) {
|
||||||
|
$table->integer('start_time_seconds')->nullable()->change();
|
||||||
|
});
|
||||||
|
Schema::table('match_points', function (Blueprint $table) {
|
||||||
|
$table->integer('timestamp_seconds')->change();
|
||||||
|
});
|
||||||
|
Schema::table('coach_reviews', function (Blueprint $table) {
|
||||||
|
$table->integer('start_time_seconds')->change();
|
||||||
|
$table->integer('end_time_seconds')->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
391
drafts/VS Screen Arena - Standalone(1).html
Normal file
391
drafts/VS Screen Arena - Standalone(1).html
Normal file
File diff suppressed because one or more lines are too long
391
drafts/VS-ScreenArena-Standalone.html
Normal file
391
drafts/VS-ScreenArena-Standalone.html
Normal file
File diff suppressed because one or more lines are too long
94
drafts/score-bar.html
Normal file
94
drafts/score-bar.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Score bar — 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:#111;font-family:'Barlow Condensed',sans-serif}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
SCORE BAR — copy this markup verbatim. Do not restructure it.
|
||||||
|
Placement: inside the player container, which must be position:relative.
|
||||||
|
Data holes are marked with {{ }} — replace ONLY those, nothing else.
|
||||||
|
Hide a part by setting its element's display to none (never remove the element's siblings' flex sizing).
|
||||||
|
|
||||||
|
Rules that break the design if changed:
|
||||||
|
- the panel has transform:skewX(-9deg); ONE inner row has transform:skewX(9deg). Never counter-skew individual children.
|
||||||
|
- every text span keeps flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis
|
||||||
|
- the logo (46px) and score (60px) boxes keep flex:none
|
||||||
|
- bottom:56px keeps the bar above the 48px control band
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div style="position:relative;width:1150px;height:200px;background:#1b1b1e">
|
||||||
|
<div style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;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);display:flex;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: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="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">{{ redName }}</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">{{ redClub }}</span>
|
||||||
|
<span style="display:none;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)">{{ redLabel }}</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)">{{ redScore }}</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 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>
|
||||||
|
|
||||||
|
<!-- BLUE / AO — exact mirror -->
|
||||||
|
<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)">{{ blueLabel }}</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)">{{ blueScore }}</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">{{ blueName }}</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: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 style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||||
|
<span style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||||
|
<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">{{ blueClub }}</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:flex;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 — one segment per point event, red/blue, #2a2724 when empty -->
|
||||||
|
<div style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
|
||||||
|
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||||
|
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||||
|
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#2a2724"></div>
|
||||||
|
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#e8534a"></div>
|
||||||
|
<div style="flex:1;background:#2a2724"></div><div style="flex:1;background:#e8534a"></div>
|
||||||
|
<div style="flex:1;background:#6aa6ff"></div><div style="flex:1;background:#2a2724"></div>
|
||||||
|
<div style="flex:1;background:#e8534a"></div><div style="flex:1;background:#2a2724"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2537
package-lock.json
generated
Normal file
2537
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -9,5 +9,8 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -479,10 +479,10 @@
|
|||||||
@else
|
@else
|
||||||
@php $maxViews = $viewsByCountry->first()->total; @endphp
|
@php $maxViews = $viewsByCountry->first()->total; @endphp
|
||||||
@foreach($viewsByCountry as $i => $row)
|
@foreach($viewsByCountry as $i => $row)
|
||||||
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? $row->country ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
<div class="country-row clickable-seg" style="cursor:pointer" onclick="openDashModal('Viewers from {{ addslashes($row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown') }}','country_viewers',{country:'{{ $row->country }}'},'bi-globe2')">
|
||||||
<div class="country-rank">{{ $i + 1 }}</div>
|
<div class="country-rank">{{ $i + 1 }}</div>
|
||||||
<div class="country-flag" title="{{ $row->country }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
<div class="country-flag" title="{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}">{!! $row->country ? countryCodeToFlag($row->country) : countryCodeToFlag('xx') !!}</div>
|
||||||
<div class="country-name">{{ $row->country_name ?? 'Unknown' }}</div>
|
<div class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) ?? 'Unknown' }}</div>
|
||||||
<div class="country-bar-wrap">
|
<div class="country-bar-wrap">
|
||||||
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
<div class="country-bar" style="width:{{ round(($row->total / $maxViews) * 100) }}%;"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -742,7 +742,11 @@ new Chart(document.getElementById('typeChart'), {
|
|||||||
// ── Country Chart ───────────────────────────────────────────────
|
// ── Country Chart ───────────────────────────────────────────────
|
||||||
@if($viewsByCountry->isNotEmpty())
|
@if($viewsByCountry->isNotEmpty())
|
||||||
(function() {
|
(function() {
|
||||||
const countryData = @json($viewsByCountry);
|
const countryData = @json($viewsByCountry->map(fn($r) => [
|
||||||
|
'country' => $r->country,
|
||||||
|
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||||
|
'total' => $r->total,
|
||||||
|
])->values());
|
||||||
const labels = countryData.map(r => r.country_name || r.country || '');
|
const labels = countryData.map(r => r.country_name || r.country || '');
|
||||||
const values = countryData.map(r => r.total);
|
const values = countryData.map(r => r.total);
|
||||||
const maxVal = Math.max(...values);
|
const maxVal = Math.max(...values);
|
||||||
@ -1015,7 +1019,10 @@ if (typeChartInst) {
|
|||||||
// Country bar chart click
|
// Country bar chart click
|
||||||
const countryChartInst = Chart.getChart('countryChart');
|
const countryChartInst = Chart.getChart('countryChart');
|
||||||
if (countryChartInst) {
|
if (countryChartInst) {
|
||||||
const countryData = @json($viewsByCountry ?? collect());
|
const countryData = @json(($viewsByCountry ?? collect())->map(fn($r) => [
|
||||||
|
'country' => $r->country,
|
||||||
|
'country_name' => $r->country_name ?? \App\Data\Countries::name($r->country),
|
||||||
|
])->values());
|
||||||
countryChartInst.options.onClick = function(evt, elements) {
|
countryChartInst.options.onClick = function(evt, elements) {
|
||||||
if (!elements.length) return;
|
if (!elements.length) return;
|
||||||
const row = countryData[elements[0].index];
|
const row = countryData[elements[0].index];
|
||||||
|
|||||||
40
resources/views/admin/og-previews.blade.php
Normal file
40
resources/views/admin/og-previews.blade.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
@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 ·
|
||||||
|
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@ -294,7 +294,7 @@ function flagEmoji(string $code): string {
|
|||||||
<div class="country-row">
|
<div class="country-row">
|
||||||
<span class="country-rank">{{ $i + 1 }}</span>
|
<span class="country-rank">{{ $i + 1 }}</span>
|
||||||
<span class="country-flag">{!! flagEmoji($row->country) !!}</span>
|
<span class="country-flag">{!! flagEmoji($row->country) !!}</span>
|
||||||
<span class="country-name">{{ $row->country_name ?? $row->country }}</span>
|
<span class="country-name">{{ $row->country_name ?? \App\Data\Countries::name($row->country) }}</span>
|
||||||
<div class="country-bar-wrap">
|
<div class="country-bar-wrap">
|
||||||
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
<div class="country-bar" style="width:{{ round(($row->total / $maxCountry) * 100) }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -423,7 +423,7 @@ function flagEmoji(string $code): string {
|
|||||||
<td>
|
<td>
|
||||||
@if($view->country)
|
@if($view->country)
|
||||||
{!! flagEmoji($view->country) !!}
|
{!! flagEmoji($view->country) !!}
|
||||||
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? $view->country }}</span>
|
<span style="font-size:13px; margin-left:4px;">{{ $view->country_name ?? \App\Data\Countries::name($view->country) }}</span>
|
||||||
@else
|
@else
|
||||||
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
<span style="color:var(--text-secondary); font-size:12px;">Unknown</span>
|
||||||
@endif
|
@endif
|
||||||
@ -498,7 +498,7 @@ new Chart(document.getElementById('dailyChart'), {
|
|||||||
|
|
||||||
// ── Country chart ───────────────────────────────────────────────────────────
|
// ── Country chart ───────────────────────────────────────────────────────────
|
||||||
@if($viewsByCountry->isNotEmpty())
|
@if($viewsByCountry->isNotEmpty())
|
||||||
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? $r->country))->values()) !!};
|
const countryLabels = {!! json_encode($viewsByCountry->map(fn($r) => ($r->country_name ?? \App\Data\Countries::name($r->country)))->values()) !!};
|
||||||
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
|
const countryData = {!! json_encode($viewsByCountry->pluck('total')->values()) !!};
|
||||||
const countryMax = Math.max(...countryData);
|
const countryMax = Math.max(...countryData);
|
||||||
|
|
||||||
|
|||||||
@ -122,7 +122,12 @@
|
|||||||
|
|
||||||
/* ── Open / close ── */
|
/* ── Open / close ── */
|
||||||
function openModal() {
|
function openModal() {
|
||||||
document.getElementById('tcOverlay_' + id).classList.add('open');
|
var ov = document.getElementById('tcOverlay_' + id);
|
||||||
|
// Reparent to <body> at open time — a transformed ancestor (e.g. an
|
||||||
|
// opening Bootstrap modal) would otherwise constrain our position:fixed
|
||||||
|
// overlay to its own stacking context and clip Cropme's canvas.
|
||||||
|
if (ov && ov.parentNode !== document.body) document.body.appendChild(ov);
|
||||||
|
ov.classList.add('open');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
}
|
}
|
||||||
window['openCropperModal_' + id] = openModal;
|
window['openCropperModal_' + id] = openModal;
|
||||||
@ -135,42 +140,161 @@
|
|||||||
if (e.target === this) window.closeCropperModal(id);
|
if (e.target === this) window.closeCropperModal(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Preload a File into the cropper ── */
|
/* ── Preload a File into the cropper ─────────────────────────────────
|
||||||
|
* Uses URL.createObjectURL (fast, memory-friendly) instead of FileReader
|
||||||
|
* data URLs, which are slow / can silently fail with large images.
|
||||||
|
*/
|
||||||
function preloadFile(file) {
|
function preloadFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
originalFile = file;
|
originalFile = file;
|
||||||
document.getElementById('tcFileName_' + id).textContent = file.name;
|
document.getElementById('tcFileName_' + id).textContent = file.name;
|
||||||
var reader = new FileReader();
|
var url = URL.createObjectURL(file);
|
||||||
reader.onload = function (e) { initCropper(e.target.result); };
|
initCropper(url);
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
}
|
||||||
window['tcPreload_' + id] = preloadFile;
|
window['tcPreload_' + id] = preloadFile;
|
||||||
|
|
||||||
function initCropper(dataUrl) {
|
/*
|
||||||
|
* ───────────────────────────────────────────────────────────────────
|
||||||
|
* DIY cropper — no library. Renders the loaded image in the canvas
|
||||||
|
* with a viewport frame of the desired vw:vh aspect (dashed red
|
||||||
|
* border, dark scrim outside via box-shadow). Pan by dragging the
|
||||||
|
* image, zoom via the slider (or wheel), rotate via the slider.
|
||||||
|
* Save clips the visible viewport region to an offscreen canvas.
|
||||||
|
* ───────────────────────────────────────────────────────────────────
|
||||||
|
*/
|
||||||
|
var _st = null; // cropper state — replaces Cropme's `cropperInst`
|
||||||
|
|
||||||
|
function initCropper(imageUrl) {
|
||||||
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
|
document.getElementById('tcPlaceholder_' + id).style.display = 'none';
|
||||||
document.getElementById('tcSaveBtn_' + id).disabled = false;
|
document.getElementById('tcSaveBtn_' + id).disabled = false;
|
||||||
document.getElementById('tcAsIsBtn_' + id).disabled = false;
|
document.getElementById('tcAsIsBtn_' + id).disabled = false;
|
||||||
|
|
||||||
var canvas = document.getElementById('tcCanvas_' + id);
|
var canvas = document.getElementById('tcCanvas_' + id);
|
||||||
if (cropperInst) { cropperInst.destroy(); cropperInst = null; }
|
canvas.innerHTML = '';
|
||||||
|
canvas.className = 'tc-canvas tc-canvas-active';
|
||||||
|
|
||||||
cropperInst = new Cropme(canvas, {
|
// ── layout: image element + viewport frame (frame drawn via box-shadow scrim) ──
|
||||||
container: { width: '100%', height: 320 },
|
var img = document.createElement('img');
|
||||||
viewport: {
|
img.alt = '';
|
||||||
width: vw, height: vh,
|
img.style.cssText =
|
||||||
type: shape,
|
'position:absolute;top:50%;left:50%;transform-origin:center center;' +
|
||||||
border: { enable: true, width: 2, color: '#ef4444' }
|
'user-select:none;-webkit-user-drag:none;pointer-events:none;max-width:none;';
|
||||||
},
|
var frame = document.createElement('div');
|
||||||
transformOrigin: 'viewport',
|
frame.style.cssText =
|
||||||
zoom: { min: zoomMin, max: zoomMax, enable: true, mouseWheel: true, slider: false },
|
'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
|
||||||
rotation: { enable: true, slider: false }
|
'border:2px dashed #ef4444;box-sizing:border-box;pointer-events:none;' +
|
||||||
});
|
'box-shadow:0 0 0 2000px rgba(0,0,0,.55);' +
|
||||||
cropperInst.bind({ url: dataUrl }).then(function () {
|
(shape === 'circle' ? 'border-radius:50%;' : '');
|
||||||
document.getElementById('tcZoom_' + id).value = 0;
|
canvas.appendChild(img);
|
||||||
document.getElementById('tcRot_' + id).value = 0;
|
canvas.appendChild(frame);
|
||||||
});
|
|
||||||
|
// Reset sliders
|
||||||
|
var zoomEl = document.getElementById('tcZoom_' + id);
|
||||||
|
var rotEl = document.getElementById('tcRot_' + id);
|
||||||
|
zoomEl.value = 0; rotEl.value = 0;
|
||||||
|
|
||||||
|
// Init state
|
||||||
|
_st = {
|
||||||
|
img: img, frame: frame, canvas: canvas,
|
||||||
|
imgW: 0, imgH: 0,
|
||||||
|
canvasW: 0, canvasH: 0,
|
||||||
|
vpW: 0, vpH: 0, // viewport display size (px inside canvas)
|
||||||
|
baseScale: 1, // scale that makes image "cover" the viewport
|
||||||
|
scale: 1, deg: 0, tx: 0, ty: 0,
|
||||||
|
ready: false,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onload = function () {
|
||||||
|
_st.imgW = img.naturalWidth || 1;
|
||||||
|
_st.imgH = img.naturalHeight || 1;
|
||||||
|
layoutCropper();
|
||||||
|
_st.ready = true;
|
||||||
|
};
|
||||||
|
img.onerror = function (e) {
|
||||||
|
console.error('[image-cropper]', id, 'image failed to load', e);
|
||||||
|
};
|
||||||
|
img.src = imageUrl;
|
||||||
|
// If already cached, onload may have fired synchronously — no-op if not.
|
||||||
|
if (img.complete && img.naturalWidth > 0) img.onload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fit the viewport inside the canvas, then fit the image to "cover" the viewport.
|
||||||
|
function layoutCropper() {
|
||||||
|
var s = _st; if (!s) return;
|
||||||
|
s.canvasW = s.canvas.clientWidth || 320;
|
||||||
|
s.canvasH = s.canvas.clientHeight || 320;
|
||||||
|
|
||||||
|
// Viewport: same aspect as vw:vh, fits inside canvas with ~10% padding.
|
||||||
|
var pad = 20;
|
||||||
|
var maxW = s.canvasW - pad * 2;
|
||||||
|
var maxH = s.canvasH - pad * 2;
|
||||||
|
var vAspect = vw / vh;
|
||||||
|
if (maxW / vAspect <= maxH) { s.vpW = maxW; s.vpH = Math.round(maxW / vAspect); }
|
||||||
|
else { s.vpH = maxH; s.vpW = Math.round(maxH * vAspect); }
|
||||||
|
s.frame.style.width = s.vpW + 'px';
|
||||||
|
s.frame.style.height = s.vpH + 'px';
|
||||||
|
|
||||||
|
// Base scale: image must at least cover the viewport.
|
||||||
|
s.baseScale = Math.max(s.vpW / s.imgW, s.vpH / s.imgH);
|
||||||
|
s.scale = s.baseScale;
|
||||||
|
s.tx = 0; s.ty = 0; s.deg = 0;
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform() {
|
||||||
|
var s = _st; if (!s) return;
|
||||||
|
// Image is centered via top/left 50% translate + own translate + scale + rotate.
|
||||||
|
// Set explicit width so scale multiplies pixels rather than object-fit.
|
||||||
|
s.img.style.width = s.imgW + 'px';
|
||||||
|
s.img.style.height = s.imgH + 'px';
|
||||||
|
s.img.style.marginLeft = (-s.imgW / 2) + 'px';
|
||||||
|
s.img.style.marginTop = (-s.imgH / 2) + 'px';
|
||||||
|
s.img.style.transform =
|
||||||
|
'translate(' + s.tx + 'px,' + s.ty + 'px)' +
|
||||||
|
' rotate(' + s.deg + 'deg)' +
|
||||||
|
' scale(' + s.scale + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag to pan (mouse + touch)
|
||||||
|
(function bindPan() {
|
||||||
|
var canvas = document.getElementById('tcCanvas_' + id);
|
||||||
|
var dragging = false, sx = 0, sy = 0, otx = 0, oty = 0;
|
||||||
|
function down(e) {
|
||||||
|
if (!_st || !_st.ready) return;
|
||||||
|
dragging = true;
|
||||||
|
var p = e.touches ? e.touches[0] : e;
|
||||||
|
sx = p.clientX; sy = p.clientY; otx = _st.tx; oty = _st.ty;
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
function move(e) {
|
||||||
|
if (!dragging || !_st) return;
|
||||||
|
var p = e.touches ? e.touches[0] : e;
|
||||||
|
_st.tx = otx + (p.clientX - sx);
|
||||||
|
_st.ty = oty + (p.clientY - sy);
|
||||||
|
applyTransform();
|
||||||
|
}
|
||||||
|
function up() { dragging = false; canvas.style.cursor = 'grab'; }
|
||||||
|
canvas.addEventListener('mousedown', down);
|
||||||
|
canvas.addEventListener('touchstart', down, { passive: false });
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('touchmove', move, { passive: false });
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
window.addEventListener('touchend', up);
|
||||||
|
// Wheel zoom
|
||||||
|
canvas.addEventListener('wheel', function (e) {
|
||||||
|
if (!_st || !_st.ready) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var delta = e.deltaY < 0 ? 1.08 : 1 / 1.08;
|
||||||
|
_st.scale = Math.max(_st.baseScale, Math.min(_st.baseScale * 6, _st.scale * delta));
|
||||||
|
var pct = Math.round(((_st.scale - _st.baseScale) / (_st.baseScale * 5)) * 100);
|
||||||
|
document.getElementById('tcZoom_' + id).value = pct;
|
||||||
|
applyTransform();
|
||||||
|
}, { passive: false });
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
})();
|
||||||
|
|
||||||
/* ── Internal file input (the "Choose image" button inside the modal) ── */
|
/* ── Internal file input (the "Choose image" button inside the modal) ── */
|
||||||
document.getElementById('tcInput_' + id).addEventListener('change', function () {
|
document.getElementById('tcInput_' + id).addEventListener('change', function () {
|
||||||
if (this.files && this.files[0]) preloadFile(this.files[0]);
|
if (this.files && this.files[0]) preloadFile(this.files[0]);
|
||||||
@ -182,14 +306,16 @@
|
|||||||
|
|
||||||
/* ── Zoom / rotate sliders ── */
|
/* ── Zoom / rotate sliders ── */
|
||||||
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
|
document.getElementById('tcZoom_' + id).addEventListener('input', function () {
|
||||||
if (!cropperInst || !cropperInst.properties.image) return;
|
if (!_st || !_st.ready) return;
|
||||||
|
// 0 → baseScale (fit), 100 → baseScale * 6 (max zoom-in)
|
||||||
var p = parseFloat(this.value) / 100;
|
var p = parseFloat(this.value) / 100;
|
||||||
cropperInst.properties.scale = zoomMin + (zoomMax - zoomMin) * p;
|
_st.scale = _st.baseScale + (_st.baseScale * 5) * p;
|
||||||
var s = cropperInst.properties;
|
applyTransform();
|
||||||
s.image.style.transform = 'translate3d(' + s.x + 'px,' + s.y + 'px,0) scale(' + s.scale + ') rotate(' + s.deg + 'deg)';
|
|
||||||
});
|
});
|
||||||
document.getElementById('tcRot_' + id).addEventListener('input', function () {
|
document.getElementById('tcRot_' + id).addEventListener('input', function () {
|
||||||
if (cropperInst) cropperInst.rotate(parseInt(this.value, 10));
|
if (!_st || !_st.ready) return;
|
||||||
|
_st.deg = parseInt(this.value, 10) || 0;
|
||||||
|
applyTransform();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Helpers ── */
|
/* ── Helpers ── */
|
||||||
@ -250,43 +376,82 @@
|
|||||||
.catch(onFail);
|
.catch(onFail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Crop & Save ── */
|
/* ── Crop & Save ─────────────────────────────────────────────────────
|
||||||
|
* Renders the visible viewport region to an offscreen canvas at the
|
||||||
|
* output size, then returns the result as a JPEG data URL.
|
||||||
|
* The image is transformed by (translate → rotate → scale) — the
|
||||||
|
* offscreen canvas replays those same transforms scaled up to the
|
||||||
|
* output resolution so nothing is lost.
|
||||||
|
*/
|
||||||
|
function renderCroppedBase64() {
|
||||||
|
var s = _st; if (!s || !s.ready) return null;
|
||||||
|
var outW = outputWidth > 0 ? outputWidth : vw;
|
||||||
|
var outH = Math.round(outW * (vh / vw));
|
||||||
|
|
||||||
|
var cv = document.createElement('canvas');
|
||||||
|
cv.width = outW;
|
||||||
|
cv.height = outH;
|
||||||
|
var ctx = cv.getContext('2d');
|
||||||
|
|
||||||
|
// Circle crop mask (matches the round viewport look on save)
|
||||||
|
if (shape === 'circle') {
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(outW / 2, outH / 2, Math.min(outW, outH) / 2, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 display px inside the viewport == (outW / s.vpW) output px
|
||||||
|
var pxRatio = outW / s.vpW;
|
||||||
|
|
||||||
|
ctx.translate(outW / 2, outH / 2);
|
||||||
|
ctx.scale(pxRatio, pxRatio);
|
||||||
|
ctx.translate(s.tx, s.ty);
|
||||||
|
ctx.rotate(s.deg * Math.PI / 180);
|
||||||
|
ctx.scale(s.scale, s.scale);
|
||||||
|
ctx.drawImage(s.img, -s.imgW / 2, -s.imgH / 2);
|
||||||
|
|
||||||
|
if (shape === 'circle') ctx.restore();
|
||||||
|
|
||||||
|
return cv.toDataURL('image/jpeg', 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
window['tcSave_' + id] = function () {
|
window['tcSave_' + id] = function () {
|
||||||
if (!cropperInst) return;
|
if (!_st || !_st.ready) return;
|
||||||
var btn = document.getElementById('tcSaveBtn_' + id);
|
var btn = document.getElementById('tcSaveBtn_' + id);
|
||||||
var txt = document.getElementById('tcSaveBtnText_' + id);
|
var txt = document.getElementById('tcSaveBtnText_' + id);
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
txt.textContent = 'Saving…';
|
txt.textContent = 'Saving…';
|
||||||
|
|
||||||
var cropOpts = outputWidth > 0 ? { type: 'base64', width: outputWidth } : { type: 'base64' };
|
var base64 = renderCroppedBase64();
|
||||||
|
if (!base64) { btn.disabled = false; txt.textContent = 'Crop & Save'; return; }
|
||||||
|
|
||||||
cropperInst.crop(cropOpts).then(function (base64) {
|
if (isCallbackMode) {
|
||||||
if (isCallbackMode) {
|
var cbName = originalFile ? originalFile.name : 'cropped.jpg';
|
||||||
var cbName = originalFile ? originalFile.name : 'cropped.png';
|
deliverResult(base64ToFile(base64, cbName));
|
||||||
deliverResult(base64ToFile(base64, cbName));
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (isFormMode) {
|
||||||
if (isFormMode) {
|
var fname = originalFile ? originalFile.name : 'cropped.jpg';
|
||||||
var fname = originalFile ? originalFile.name : 'cropped.png';
|
setOnTargetInput(base64ToFile(base64, fname));
|
||||||
setOnTargetInput(base64ToFile(base64, fname));
|
window.closeCropperModal(id);
|
||||||
|
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
||||||
|
btn.disabled = false;
|
||||||
|
txt.textContent = 'Crop & Save';
|
||||||
|
} else {
|
||||||
|
uploadToServer(base64, function (res) {
|
||||||
window.closeCropperModal(id);
|
window.closeCropperModal(id);
|
||||||
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
|
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
||||||
|
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
txt.textContent = 'Crop & Save';
|
txt.textContent = 'Crop & Save';
|
||||||
} else {
|
}, function (err) {
|
||||||
uploadToServer(base64, function (res) {
|
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
||||||
window.closeCropperModal(id);
|
btn.disabled = false;
|
||||||
if (typeof window.showToast === 'function') window.showToast('Saved!', 'success');
|
txt.textContent = 'Crop & Save';
|
||||||
if (callbackFn && typeof window[callbackFn] === 'function') window[callbackFn](res.url);
|
});
|
||||||
btn.disabled = false;
|
}
|
||||||
txt.textContent = 'Crop & Save';
|
|
||||||
}, function (err) {
|
|
||||||
if (typeof window.showToast === 'function') window.showToast(err.message || 'Upload failed', 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
txt.textContent = 'Crop & Save';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Upload as-is ── */
|
/* ── Upload as-is ── */
|
||||||
|
|||||||
@ -3,9 +3,38 @@
|
|||||||
/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 */
|
/* Base styles for video card — every card in the grid renders at the same
|
||||||
|
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 {
|
||||||
@ -61,7 +90,11 @@
|
|||||||
.yt-video-card .yt-video-thumb video {
|
.yt-video-card .yt-video-thumb video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
/* Match the still-image thumbnail: always cover the 16/9 box so
|
||||||
|
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;
|
||||||
@ -212,6 +245,19 @@
|
|||||||
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 {
|
||||||
@ -237,20 +283,28 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
/* Single-line, no wrapping — overflow ellipses. */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-video-title a {
|
.yt-video-card .yt-video-title a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: flex;
|
/* Block-level with nowrap so ellipsis actually clips the excess.
|
||||||
align-items: baseline;
|
Inline-flex would let the children overflow past the parent. */
|
||||||
gap: 5px;
|
display: block;
|
||||||
|
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;
|
||||||
@ -274,6 +328,8 @@
|
|||||||
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 {
|
||||||
|
|||||||
377
resources/views/components/partials/scorebar-mini.blade.php
Normal file
377
resources/views/components/partials/scorebar-mini.blade.php
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
{{-- ══════════════════════════════════════════════════════════════════════
|
||||||
|
SCOREBAR MINI — the same match scorebar shown on the full player,
|
||||||
|
rendered inside a video-card thumbnail during hover playback.
|
||||||
|
|
||||||
|
Uses the design's fixed 1150 px canvas (identical to the main player's
|
||||||
|
msb-scale block). Every card scales its own canvas via ResizeObserver
|
||||||
|
so the scorebar keeps its pixel-perfect proportions at any card size.
|
||||||
|
|
||||||
|
Static-only: fighter names, flags, clubs, corner labels, "Round 1"
|
||||||
|
and 0-0 / 0:00. No live-scoring script — hover previews cover the
|
||||||
|
first few seconds of the match where those values are always the
|
||||||
|
baseline anyway.
|
||||||
|
════════════════════════════════════════════════════════════════════════ --}}
|
||||||
|
@php
|
||||||
|
$sbm_hd = $video->sportsMatch?->headerData();
|
||||||
|
$sbm_red = $sbm_hd['red'] ?? null;
|
||||||
|
$sbm_blue = $sbm_hd['blue'] ?? null;
|
||||||
|
$sbm_show = $sbm_hd && (($sbm_red['name'] ?? null) || ($sbm_blue['name'] ?? null));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($sbm_show)
|
||||||
|
@php
|
||||||
|
$sbm_redName = $sbm_red['name'] ?? 'RED';
|
||||||
|
$sbm_blueName = $sbm_blue['name'] ?? 'BLUE';
|
||||||
|
$sbm_redClub = $sbm_red['club'] ?? '';
|
||||||
|
$sbm_blueClub = $sbm_blue['club'] ?? '';
|
||||||
|
$sbm_redFlag = strtolower(trim((string) ($sbm_red['flag'] ?? '')));
|
||||||
|
$sbm_blueFlag = strtolower(trim((string) ($sbm_blue['flag'] ?? '')));
|
||||||
|
$sbm_redLogo = !empty($sbm_red['club_logo']) ? route('media.thumbnail', $sbm_red['club_logo']) : null;
|
||||||
|
$sbm_blueLogo = !empty($sbm_blue['club_logo']) ? route('media.thumbnail', $sbm_blue['club_logo']) : null;
|
||||||
|
|
||||||
|
// Live-scoring payload — same shape as the full-player scoreboard uses.
|
||||||
|
// Rounds drive the round label + per-round clock. Points give us the
|
||||||
|
// score at any t (last point ≤ t defines the score).
|
||||||
|
$sbm_rounds = $video->matchRounds()
|
||||||
|
->orderBy('round_number')
|
||||||
|
->get(['round_number', 'name', 'start_time_seconds'])
|
||||||
|
->map(fn ($r) => [
|
||||||
|
'n' => (int) $r->round_number,
|
||||||
|
'name' => (string) ($r->name ?? ''),
|
||||||
|
'start' => (float) ($r->start_time_seconds ?? 0),
|
||||||
|
])->values()->all();
|
||||||
|
if (empty($sbm_rounds)) $sbm_rounds = [['n' => 1, 'name' => '', 'start' => 0]];
|
||||||
|
|
||||||
|
$sbm_points = $video->matchPoints()
|
||||||
|
->orderBy('timestamp_seconds')
|
||||||
|
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
|
||||||
|
->flatMap(function ($p) {
|
||||||
|
// Same rule the full-player uses: a "both" event emits two feed rows
|
||||||
|
// — one per corner — so the ticker shows both fighters scoring.
|
||||||
|
$side = ($p->competitor === 'both') ? 'red' : $p->competitor;
|
||||||
|
$rows = [[
|
||||||
|
't' => (float) $p->timestamp_seconds,
|
||||||
|
'action' => (string) ($p->action ?? 'Point'),
|
||||||
|
'pts' => (int) $p->points,
|
||||||
|
'side' => $side,
|
||||||
|
'sr' => (int) ($p->score_red ?? 0),
|
||||||
|
'sb' => (int) ($p->score_blue ?? 0),
|
||||||
|
]];
|
||||||
|
if ($p->competitor === 'both') {
|
||||||
|
$rows[] = array_merge($rows[0], ['side' => 'blue', 't' => (float) $p->timestamp_seconds + 0.001]);
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
})->values()->all();
|
||||||
|
|
||||||
|
$sbm_sport = $sbm_hd['sport'] ?? null;
|
||||||
|
|
||||||
|
$sbm_data = json_encode(['rounds' => $sbm_rounds, 'points' => $sbm_points, 'sport' => $sbm_sport], JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="sbm" data-sbm data-sbm-state='{{ $sbm_data }}'>
|
||||||
|
{{-- Live scoring ticker — sits in its OWN scaled canvas at the
|
||||||
|
top-right of the thumb. Populated by the sbm live-sync JS. --}}
|
||||||
|
<div class="sbm-feed-scale">
|
||||||
|
<div class="sbm-feed" data-sbm-feed>
|
||||||
|
<div class="sbm-feed-hdr"><span class="sbm-feed-dot"></span><span>Live scoring</span></div>
|
||||||
|
<div class="sbm-feed-list" data-sbm-feed-list></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sbm-scale">
|
||||||
|
{{-- ══ Scorebar markup — same design + inline styles as the
|
||||||
|
overlay.blade.php `data-msb-part="scorebar"` block, but stripped
|
||||||
|
of the live-sync data-msb hooks. ══ --}}
|
||||||
|
<div style="position:absolute;left:0;right:0;bottom:14px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;pointer-events:none">
|
||||||
|
|
||||||
|
{{-- RED / AKA --}}
|
||||||
|
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||||
|
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||||
|
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||||
|
@if($sbm_redLogo) background-image:url('{{ $sbm_redLogo }}'); @endif"></div>
|
||||||
|
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||||
|
@if ($sbm_redFlag !== '')
|
||||||
|
<span class="fi fi-{{ $sbm_redFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||||
|
@endif
|
||||||
|
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redName }}</span>
|
||||||
|
</div>
|
||||||
|
@if ($sbm_redClub !== '')
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||||
|
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_redClub }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||||
|
<span style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
||||||
|
<span data-sbm-red-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CENTER: round + clock --}}
|
||||||
|
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||||
|
<div data-sbm-round style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
||||||
|
<div data-sbm-clock style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
|
||||||
|
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||||
|
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||||
|
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||||
|
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- BLUE / AO --}}
|
||||||
|
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||||
|
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||||
|
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
||||||
|
<span style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
||||||
|
<span data-sbm-blue-score style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||||
|
<span style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $sbm_blueName }}</span>
|
||||||
|
@if ($sbm_blueFlag !== '')
|
||||||
|
<span class="fi fi-{{ $sbm_blueFlag ?: 'xx' }}" style="width:26px;height:17px;flex:none;box-shadow:0 0 0 1px rgba(255,255,255,.3);background-size:cover !important;background-position:center !important;display:inline-block"></span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if ($sbm_blueClub !== '')
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;justify-content:flex-end">
|
||||||
|
<span style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right">{{ $sbm_blueClub }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);background-size:cover;background-position:center;
|
||||||
|
@if($sbm_blueLogo) background-image:url('{{ $sbm_blueLogo }}'); @endif"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@once
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Zen+Old+Mincho:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ══ SCOREBAR MINI — same 1150 px canvas as the full player scorebar.
|
||||||
|
Sits above the hover-preview <video> (z-index 6, one above the
|
||||||
|
video at 5) and only appears while the video is active. ══ */
|
||||||
|
.yt-video-thumb .sbm {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
color: #efe9e0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .25s ease .05s;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
--sbm-scale: 0.4;
|
||||||
|
}
|
||||||
|
.sbm * { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Show only while the hover video is playing (playVideo() adds .active) */
|
||||||
|
.yt-video-thumb:has(video.active) .sbm { opacity: 1; }
|
||||||
|
|
||||||
|
.sbm .sbm-scale {
|
||||||
|
position: absolute; bottom: 0; left: 0;
|
||||||
|
width: 1150px; height: 100px;
|
||||||
|
transform-origin: bottom left;
|
||||||
|
transform: scale(var(--sbm-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Live scoring feed (top-right) — SAME 1150 px design canvas as the
|
||||||
|
full-player msb-feed, so the sizes and positions are pixel-identical.
|
||||||
|
Scaled by --sbm-scale (same as the bottom scorebar) so the feed
|
||||||
|
shrinks/grows in lockstep with the rest of the overlay. ── */
|
||||||
|
.sbm .sbm-feed-scale {
|
||||||
|
position: absolute; top: 0; left: 0;
|
||||||
|
width: 1150px; height: 100%;
|
||||||
|
transform-origin: top left;
|
||||||
|
transform: scale(var(--sbm-scale));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* Verbatim copy of .msb-feed / .msb-feed .hdr / .msb-feed .entry from
|
||||||
|
videos/partials/match/scoreboard/styles.blade.php — only the class
|
||||||
|
names are prefixed .sbm- to avoid colliding with the full player
|
||||||
|
when a card is opened as a sub-page. */
|
||||||
|
.sbm .sbm-feed {
|
||||||
|
position: absolute; top: 22px; right: 24px;
|
||||||
|
width: 262px;
|
||||||
|
display: flex; flex-direction: column; gap: 7px;
|
||||||
|
}
|
||||||
|
.sbm .sbm-feed-hdr {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end; gap: 8px;
|
||||||
|
font-size: 12px; letter-spacing: .3em; color: #d5cfc7; text-transform: uppercase;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
text-shadow: 0 1px 6px rgba(0,0,0,.9);
|
||||||
|
}
|
||||||
|
.sbm .sbm-feed-dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: #e8534a; animation: sbmPulse 1.6s infinite;
|
||||||
|
}
|
||||||
|
.sbm .sbm-feed-list { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.sbm .sbm-feed-entry {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: rgba(10,10,12,.62); backdrop-filter: blur(6px);
|
||||||
|
border-right: 3px solid transparent;
|
||||||
|
animation: sbmRiseIn .45s ease both;
|
||||||
|
}
|
||||||
|
.sbm .sbm-feed-entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; font-family: 'Barlow Condensed', sans-serif; }
|
||||||
|
.sbm .sbm-feed-entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; font-family: 'Barlow Condensed', sans-serif; }
|
||||||
|
.sbm .sbm-feed-entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
|
||||||
|
|
||||||
|
/* Hide feed on very small thumbs (matches the msb-small breakpoint scaled
|
||||||
|
down for cards — below ~340 px it becomes illegible). */
|
||||||
|
.sbm.sbm-small .sbm-feed { display: none; }
|
||||||
|
|
||||||
|
@keyframes sbmPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||||
|
@keyframes sbmRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (window._sbmBound) return;
|
||||||
|
window._sbmBound = true;
|
||||||
|
|
||||||
|
// ── Fit the fixed 1150 px scorebar canvas to the parent thumb ──
|
||||||
|
function fit(el) {
|
||||||
|
const host = el.parentElement;
|
||||||
|
if (!host) return;
|
||||||
|
const w = host.clientWidth;
|
||||||
|
if (!w) return;
|
||||||
|
el.style.setProperty('--sbm-scale', (w / 1150));
|
||||||
|
// Below ~340 px thumb width the ticker would shrink under legibility
|
||||||
|
// (matches the full player's `msb-small` breakpoint at 700 px, halved
|
||||||
|
// for card contexts). Hide the feed on those tiny cards.
|
||||||
|
el.classList.toggle('sbm-small', w < 340);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live scoring sync ─────────────────────────────────────────
|
||||||
|
// Each .sbm carries its match state as a data attribute. Bind the
|
||||||
|
// sibling <video>'s timeupdate to updates on THIS card's scorebar
|
||||||
|
// only — no globals, no ID collisions with other cards on the page.
|
||||||
|
function fmtClock(sec) {
|
||||||
|
sec = Math.max(0, Math.floor(sec || 0));
|
||||||
|
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
function currentRound(rounds, t) {
|
||||||
|
if (!rounds || !rounds.length) return { n: 1, name: '', start: 0 };
|
||||||
|
let cur = rounds[0];
|
||||||
|
for (const r of rounds) { if (t >= (r.start || 0)) cur = r; }
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
function lastPoint(points, t) {
|
||||||
|
if (!points || !points.length) return null;
|
||||||
|
let last = null;
|
||||||
|
for (const p of points) { if (p.t <= t + 0.001) last = p; else break; }
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
// Same label rules as the full player: karate → Yuko/Waza-ari/Ippon,
|
||||||
|
// taekwondo → Punch/Body kick/Head kick/Turning body, else action text.
|
||||||
|
function pointLabel(p, sport) {
|
||||||
|
sport = (sport || '').toLowerCase();
|
||||||
|
if (sport.startsWith('taekwondo')) {
|
||||||
|
if (p.pts >= 4) return 'Turning body';
|
||||||
|
if (p.pts === 3) return 'Head kick';
|
||||||
|
if (p.pts === 2) return 'Body kick';
|
||||||
|
if (p.pts === 1) return 'Punch';
|
||||||
|
}
|
||||||
|
if (p.pts === 3) return 'Ippon';
|
||||||
|
if (p.pts === 2) return 'Waza-ari';
|
||||||
|
if (p.pts === 1) return 'Yuko';
|
||||||
|
return p.action || 'Point';
|
||||||
|
}
|
||||||
|
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
|
const feedColor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
|
||||||
|
|
||||||
|
function bindLive(sbm) {
|
||||||
|
let state = null;
|
||||||
|
try { state = JSON.parse(sbm.getAttribute('data-sbm-state') || 'null'); }
|
||||||
|
catch (e) { return; }
|
||||||
|
if (!state) return;
|
||||||
|
|
||||||
|
const thumb = sbm.parentElement;
|
||||||
|
const video = thumb && thumb.querySelector(':scope > video');
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const redEl = sbm.querySelector('[data-sbm-red-score]');
|
||||||
|
const blueEl = sbm.querySelector('[data-sbm-blue-score]');
|
||||||
|
const roundEl = sbm.querySelector('[data-sbm-round]');
|
||||||
|
const clockEl = sbm.querySelector('[data-sbm-clock]');
|
||||||
|
const feedEl = sbm.querySelector('[data-sbm-feed-list]');
|
||||||
|
let lastR = null, lastB = null, lastRoundTxt = null, lastClk = null, lastFeedSig = null;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const t = video.currentTime || 0;
|
||||||
|
const p = lastPoint(state.points, t);
|
||||||
|
const r = currentRound(state.rounds, t);
|
||||||
|
|
||||||
|
const sr = p ? p.sr : 0, sb = p ? p.sb : 0;
|
||||||
|
if (sr !== lastR) { if (redEl) redEl.textContent = sr; lastR = sr; }
|
||||||
|
if (sb !== lastB) { if (blueEl) blueEl.textContent = sb; lastB = sb; }
|
||||||
|
|
||||||
|
const roundTxt = r.name || ('Round ' + r.n);
|
||||||
|
if (roundTxt !== lastRoundTxt) { if (roundEl) roundEl.textContent = roundTxt; lastRoundTxt = roundTxt; }
|
||||||
|
|
||||||
|
const clk = fmtClock(Math.max(0, t - (r.start || 0)));
|
||||||
|
if (clk !== lastClk) { if (clockEl) clockEl.textContent = clk; lastClk = clk; }
|
||||||
|
|
||||||
|
// Live scoring ticker — newest 3 point events, newest on top.
|
||||||
|
if (feedEl) {
|
||||||
|
const seen = (state.points || []).filter(pt => pt.t <= t + 0.001);
|
||||||
|
const last4 = seen.slice(-4).reverse();
|
||||||
|
const sig = last4.map(pt => pt.t + ':' + pt.side + ':' + pt.pts).join(',');
|
||||||
|
if (sig !== lastFeedSig) {
|
||||||
|
feedEl.innerHTML = last4.map(pt => {
|
||||||
|
const col = feedColor(pt.side);
|
||||||
|
return '<div class="sbm-feed-entry" style="border-color:' + col + '">'
|
||||||
|
+ '<span class="ts">' + fmtClock(pt.t) + '</span>'
|
||||||
|
+ '<span class="name">' + esc(pointLabel(pt, state.sport)) + '</span>'
|
||||||
|
+ '<span class="pts" style="color:' + col + '">+' + pt.pts + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
lastFeedSig = sig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
video.addEventListener('timeupdate', tick);
|
||||||
|
video.addEventListener('seeked', tick);
|
||||||
|
video.addEventListener('loadedmetadata', tick);
|
||||||
|
// Reset to 0-0 / 0:00 when the video stops (matches VS mini restart).
|
||||||
|
video.addEventListener('pause', tick);
|
||||||
|
video.addEventListener('emptied', tick);
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const el = e.target.querySelector(':scope > .sbm');
|
||||||
|
if (el) fit(el);
|
||||||
|
}
|
||||||
|
}) : null;
|
||||||
|
|
||||||
|
function register(el) {
|
||||||
|
fit(el);
|
||||||
|
if (ro) ro.observe(el.parentElement);
|
||||||
|
if (!el._sbmBound) { el._sbmBound = true; bindLive(el); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.sbm').forEach(register);
|
||||||
|
|
||||||
|
new MutationObserver(muts => {
|
||||||
|
for (const m of muts) {
|
||||||
|
for (const n of m.addedNodes) {
|
||||||
|
if (!(n instanceof Element)) continue;
|
||||||
|
if (n.matches?.('.sbm')) register(n);
|
||||||
|
n.querySelectorAll?.('.sbm').forEach(register);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endonce
|
||||||
604
resources/views/components/partials/vs-mini.blade.php
Normal file
604
resources/views/components/partials/vs-mini.blade.php
Normal file
@ -0,0 +1,604 @@
|
|||||||
|
{{-- ══════════════════════════════════════════════════════════════════════
|
||||||
|
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.5–1.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
|
||||||
@ -71,10 +71,14 @@
|
|||||||
color: var(--brand-red) !important;
|
color: var(--brand-red) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Single Action dropdown for every viewport — the individual
|
||||||
|
.desktop-action buttons are collapsed into this one menu. */
|
||||||
.mobile-action-dropdown {
|
.mobile-action-dropdown {
|
||||||
display: none;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
.video-actions > .desktop-action { display: none !important; }
|
||||||
|
|
||||||
.mobile-action-dropdown .dropdown-menu {
|
.mobile-action-dropdown .dropdown-menu {
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@ -41,11 +41,44 @@ $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',
|
||||||
default => '',
|
default => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
|
<div class="yt-video-card {{ $sizeClasses }}" data-video-url="{{ $videoUrl }}">
|
||||||
@ -53,11 +86,21 @@ $sizeClasses = match($size) {
|
|||||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
||||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||||
|
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||||
|
@include('components.partials.vs-mini', ['video' => $video])
|
||||||
|
@endif
|
||||||
@if($videoUrl)
|
@if($videoUrl)
|
||||||
<video preload="none">
|
{{-- Match cards preload metadata so the first frame is ready the
|
||||||
|
instant the user hovers (otherwise the browser fetches, buffers
|
||||||
|
then paints — showing a black frame behind the fade). Other
|
||||||
|
card types keep preload="none" to save bandwidth. --}}
|
||||||
|
<video preload="{{ $video && $video->type === 'match' ? 'metadata' : 'none' }}" playsinline>
|
||||||
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
|
||||||
</video>
|
</video>
|
||||||
@endif
|
@endif
|
||||||
|
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||||
|
@include('components.partials.scorebar-mini', ['video' => $video])
|
||||||
|
@endif
|
||||||
{{-- Equalizer overlay shown when audio-only track is previewing --}}
|
{{-- Equalizer overlay shown when audio-only track is previewing --}}
|
||||||
<div class="audio-preview-overlay">
|
<div class="audio-preview-overlay">
|
||||||
<div class="audio-eq">
|
<div class="audio-eq">
|
||||||
@ -102,7 +145,11 @@ $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
|
||||||
{{ $video->title ?? 'Untitled Video' }}
|
@if($displayTitleHtml)
|
||||||
|
{!! $displayTitleHtml !!}
|
||||||
|
@else
|
||||||
|
{{ $displayTitle ?? 'Untitled Video' }}
|
||||||
|
@endif
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
@if($video && $video->user)
|
@if($video && $video->user)
|
||||||
|
|||||||
@ -220,23 +220,31 @@
|
|||||||
background: #000;
|
background: #000;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* default aspect ratio; overridden per orientation */
|
/* Always 16:9 — no max-height so the shape is never distorted. */
|
||||||
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;
|
||||||
}
|
}
|
||||||
.ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
|
/* Orientation classes intentionally forced to 16:9 too, so the frame
|
||||||
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }
|
shape never changes with the underlying media orientation. The video
|
||||||
.ytp-wrap.ultrawide { aspect-ratio: 21/9; max-height: 65vh; }
|
element itself uses object-fit: contain and will letterbox as needed. */
|
||||||
|
.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 */
|
/* Theater mode — keep 16:9 shape, just remove rounded corners. */
|
||||||
.ytp-wrap.theater {
|
.ytp-wrap.theater {
|
||||||
max-height: 80vh;
|
aspect-ratio: 16/9;
|
||||||
aspect-ratio: unset;
|
height: auto;
|
||||||
height: 80vh;
|
max-height: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -534,10 +542,23 @@
|
|||||||
background: rgba(28,28,28,.95);
|
background: rgba(28,28,28,.95);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
overflow: hidden;
|
/* Cap height so the panel stays inside the player and scrolls when the
|
||||||
|
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;
|
||||||
@ -591,10 +612,10 @@
|
|||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.ytp-wrap { border-radius: 0; max-height: 56vw; margin: 0; width: 100%; }
|
.ytp-wrap { border-radius: 0; margin: 0; width: 100%; aspect-ratio: 16/9; max-height: none; }
|
||||||
.ytp-wrap.portrait { max-height: 75vh; width: auto; max-width: 100%; }
|
.ytp-wrap.portrait,
|
||||||
.ytp-wrap.square { max-height: 85vw; max-width: 85vw; }
|
.ytp-wrap.square,
|
||||||
.ytp-wrap.ultrawide { max-height: 50vw; }
|
.ytp-wrap.ultrawide { aspect-ratio: 16/9; width: 100%; max-width: none; max-height: none; }
|
||||||
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
|
.video-view-page .ytp-wrap ~ * { padding-left: 16px; padding-right: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -417,8 +417,10 @@
|
|||||||
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
|
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
|
||||||
Six form-mode croppers write the cropped file straight onto each hidden input;
|
Six form-mode croppers write the cropped file straight onto each hidden input;
|
||||||
one callback-mode cropper serves all dynamic official rows. --}}
|
one callback-mode cropper serves all dynamic official rows. --}}
|
||||||
<x-image-cropper id="smc_media_participant1_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
|
{{-- Fighter photos: 3:4 portrait crop to match the VS card frame (228×304).
|
||||||
<x-image-cropper id="smc_media_participant2_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
|
Club logos + referee: 1:1 square. --}}
|
||||||
|
<x-image-cropper id="smc_media_participant1_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
|
||||||
|
<x-image-cropper id="smc_media_participant2_photo" :width="285" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
|
||||||
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
|
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
|
||||||
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
|
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
|
||||||
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />
|
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />
|
||||||
@ -877,8 +879,12 @@
|
|||||||
clearErrors();
|
clearErrors();
|
||||||
lockButtons(true);
|
lockButtons(true);
|
||||||
|
|
||||||
// Edit mode → just update the record. Create mode → upload the video first.
|
// Skip the video-upload step when:
|
||||||
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst();
|
// - editing an existing match (matchId present), OR
|
||||||
|
// - creating a match against an already-attached video (videoId present via
|
||||||
|
// attachExistingVideo, e.g. clicking "Edit" on a match-type video that has
|
||||||
|
// no SportsMatch record yet — the video is real, we're just adding match data).
|
||||||
|
const chain = (idInput.value || videoIdInp.value) ? Promise.resolve() : uploadVideoFirst();
|
||||||
chain
|
chain
|
||||||
.then(() => postMatch(intent))
|
.then(() => postMatch(intent))
|
||||||
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
|
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
|
||||||
|
|||||||
@ -7,18 +7,28 @@
|
|||||||
/* ── Regular video grid ── */
|
/* ── Regular video grid ── */
|
||||||
.yt-video-grid {
|
.yt-video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
/* minmax(0, 1fr) keeps every column exactly equal in width.
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
.yt-video-grid > .yt-video-card { height: 100%; min-width: 0; }
|
||||||
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
|
@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: minmax(0, 1fr); gap: 14px; } }
|
||||||
|
|
||||||
|
|
||||||
/* ── Thumbnail orientation fix ── */
|
/* ── Thumbnail orientation fix ── */
|
||||||
|
|
||||||
|
|
||||||
/* ── Thumbnail orientation fix ── */
|
/* ── Thumbnail orientation fix ──
|
||||||
.yt-video-card .yt-video-thumb img { object-fit: cover; }
|
Always cover so every thumbnail visually fills its 16/9 box the
|
||||||
|
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; }
|
||||||
@ -207,18 +217,11 @@
|
|||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
/* Thumbnails render at a uniform size: the .yt-video-thumb box is a
|
||||||
function adjustPlThumb(img) {
|
strict 16/9 container and every image inside it uses `object-fit:
|
||||||
img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover';
|
cover` (set in the shared card CSS) so portrait/square art fills the
|
||||||
}
|
frame instead of shrinking with letterboxing. No per-image adjustment
|
||||||
document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
|
needed. */
|
||||||
if (img.complete && img.naturalWidth) {
|
|
||||||
adjustPlThumb(img);
|
|
||||||
} else {
|
|
||||||
img.addEventListener('load', function () { adjustPlThumb(img); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
/* ─── Filter-bar horizontal scroll ─── */
|
/* ─── Filter-bar horizontal scroll ─── */
|
||||||
(function () {
|
(function () {
|
||||||
@ -301,13 +304,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-run any grid-scoped init that the page's other IIFE did on first load.
|
// No per-image sizing needed — CSS `object-fit: cover` on
|
||||||
function reinitGrid(root) {
|
// `.yt-video-thumb img` gives every card the same visible image size.
|
||||||
root.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
|
function reinitGrid(_root) {}
|
||||||
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) {
|
||||||
|
|||||||
@ -0,0 +1,76 @@
|
|||||||
|
@php
|
||||||
|
/*
|
||||||
|
* Match scoreboard entry partial. Builds the JSON payload the client
|
||||||
|
* script consumes ($msbState) plus a rich $vs payload for the VS card.
|
||||||
|
* Every value is null-safe — missing fields become empty strings so
|
||||||
|
* the CSS :empty rules hide their elements (no placeholder text).
|
||||||
|
*/
|
||||||
|
$hd = $video->sportsMatch?->headerData();
|
||||||
|
|
||||||
|
/* ── Discipline / subtitle text ───────────────────────────────────── */
|
||||||
|
$subtitlePieces = array_filter([
|
||||||
|
$hd['championship'] ?? null,
|
||||||
|
$hd['weight_category'] ?? null,
|
||||||
|
!empty($hd['court']) ? ('Tatami '.$hd['court']) : null,
|
||||||
|
], fn ($p) => is_string($p) && trim($p) !== '');
|
||||||
|
|
||||||
|
/* ── Playback-time-driven data (rounds + points) ─────────────────── */
|
||||||
|
$msbRounds = $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();
|
||||||
|
|
||||||
|
$msbPoints = $video->matchPoints()
|
||||||
|
->orderBy('timestamp_seconds')
|
||||||
|
->get(['timestamp_seconds', 'action', 'points', 'competitor', 'score_red', 'score_blue'])
|
||||||
|
->flatMap(function ($p) {
|
||||||
|
// "Both" events (if ever recorded) emit two feed rows — one per corner.
|
||||||
|
$rows = [];
|
||||||
|
$rows[] = [
|
||||||
|
't' => (float) $p->timestamp_seconds,
|
||||||
|
'action' => (string) ($p->action ?? 'Point'),
|
||||||
|
'pts' => (int) $p->points,
|
||||||
|
'side' => $p->competitor === 'both' ? 'red' : $p->competitor,
|
||||||
|
'sr' => (int) ($p->score_red ?? 0),
|
||||||
|
'sb' => (int) ($p->score_blue ?? 0),
|
||||||
|
];
|
||||||
|
if ($p->competitor === 'both') {
|
||||||
|
$rows[] = $rows[0];
|
||||||
|
$rows[1]['side'] = 'blue';
|
||||||
|
$rows[1]['t'] = (float) $p->timestamp_seconds + 0.001;
|
||||||
|
}
|
||||||
|
return $rows;
|
||||||
|
})->values()->all();
|
||||||
|
|
||||||
|
$msbState = [
|
||||||
|
'sport' => $hd['sport'] ?? null,
|
||||||
|
'discipline' => null,
|
||||||
|
'subtitle' => implode(' · ', $subtitlePieces),
|
||||||
|
'red' => [
|
||||||
|
'name' => $hd['red']['name'] ?? null,
|
||||||
|
'flag' => $hd['red']['flag'] ?? null,
|
||||||
|
'club' => $hd['red']['club'] ?? null,
|
||||||
|
'club_logo' => !empty($hd['red']['club_logo'])
|
||||||
|
? route('media.thumbnail', $hd['red']['club_logo']) : null,
|
||||||
|
],
|
||||||
|
'blue' => [
|
||||||
|
'name' => $hd['blue']['name'] ?? null,
|
||||||
|
'flag' => $hd['blue']['flag'] ?? null,
|
||||||
|
'club' => $hd['blue']['club'] ?? null,
|
||||||
|
'club_logo' => !empty($hd['blue']['club_logo'])
|
||||||
|
? route('media.thumbnail', $hd['blue']['club_logo']) : null,
|
||||||
|
],
|
||||||
|
'rounds' => $msbRounds ?: [['n' => 1, 'name' => '', 'start' => 0]],
|
||||||
|
'points' => $msbPoints,
|
||||||
|
'defaults' => is_array($hd['scoreboard_defaults'] ?? null) ? $hd['scoreboard_defaults'] : [],
|
||||||
|
];
|
||||||
|
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@include('videos.partials.match.scoreboard.styles')
|
||||||
|
@include('videos.partials.match.scoreboard.overlay')
|
||||||
|
@include('videos.partials.match.scoreboard.script', ['msbState' => $msbState])
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
{{--
|
||||||
|
Overlay markup. The score bar + timeline blocks are the design's exact
|
||||||
|
verbatim inline-styled markup from drafts/score-bar.html — only the
|
||||||
|
{{ }} data holes have been filled and `data-msb` attributes added for
|
||||||
|
the JS. `data-msb-part` attributes let the gear-menu toggles hide any
|
||||||
|
part with CSS `display: none` without touching the inline styles.
|
||||||
|
--}}
|
||||||
|
<div class="msb-root" id="msbRoot" data-video-id="{{ $video->id }}">
|
||||||
|
<div class="msb-scrim"></div>
|
||||||
|
|
||||||
|
{{-- Fixed-width scale layer (1150 px design canvas). JS scales it to
|
||||||
|
fit the actual player, so the score bar keeps its pixel-perfect
|
||||||
|
proportions at any player size. --}}
|
||||||
|
<div class="msb-scale" id="msbScale">
|
||||||
|
|
||||||
|
{{-- Match info (top-left) --}}
|
||||||
|
<div class="msb-info" data-msb="info" data-msb-part="info">
|
||||||
|
<div class="rule"></div>
|
||||||
|
<div class="txt">
|
||||||
|
<div class="disc" data-msb="disc"></div>
|
||||||
|
<div class="sub" data-msb="sub"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Live scoring feed (top-right) --}}
|
||||||
|
<div class="msb-feed" data-msb="feedWrap" data-msb-part="feed">
|
||||||
|
<div class="hdr"><span class="dot"></span><span>Live scoring</span></div>
|
||||||
|
<div class="list" data-msb="feed"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
SCORE BAR — verbatim from drafts/score-bar.html (design handoff).
|
||||||
|
Only {{ }} holes replaced with Blade data + data-msb hooks.
|
||||||
|
═══════════════════════════════════════════════════════════════ --}}
|
||||||
|
<div data-msb-part="scorebar" style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;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 data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;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 data-msb="redFlag" data-msb-part="flags" 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:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||||
|
<span data-msb="redName" 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">{{ $msbState['red']['name'] ?: 'RED' }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||||
|
<span data-msb="redClubName" data-msb-part="clubs" 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">{{ $msbState['red']['club'] ?? '' }}</span>
|
||||||
|
<span data-msb="redSenshu" data-msb-part="penalties" style="display:none;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 data-msb="redCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
||||||
|
<span data-msb="redScore" 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-msb="roundLabel" style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
||||||
|
<div data-msb="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 — exact mirror --}}
|
||||||
|
<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 data-msb="blueCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
||||||
|
<span data-msb="blueScore" 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 data-msb="blueName" 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">{{ $msbState['blue']['name'] ?: 'BLUE' }}</span>
|
||||||
|
<span data-msb="blueFlag" data-msb-part="flags" 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: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 style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||||
|
<span data-msb="bluePenalty" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||||
|
<span data-msb="blueClubName" data-msb-part="clubs" 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">{{ $msbState['blue']['club'] ?? '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;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 — one segment per point event, red/blue, #2a2724 when empty --}}
|
||||||
|
<div data-msb="timeline" data-msb-part="timeline" style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
|
||||||
|
@for ($i = 0; $i < 14; $i++)
|
||||||
|
<div style="flex:1;background:#2a2724"></div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>{{-- /.msb-scale --}}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,329 @@
|
|||||||
|
{{--
|
||||||
|
Scoreboard runtime — one IIFE, deferred to DOMContentLoaded.
|
||||||
|
|
||||||
|
Consumes the JSON blob `MSB_STATE` (produced by index.blade.php) and:
|
||||||
|
• updates score/round/clock/feed/timeline every time the video's
|
||||||
|
currentTime changes (memoised so the ticker animation only replays
|
||||||
|
when the visible entries change);
|
||||||
|
• injects a "Scoreboard" section into #ytpSettingsPanel with a toggle
|
||||||
|
row for each part;
|
||||||
|
• persists toggle state in localStorage['msb_prefs'];
|
||||||
|
• drives the VS intro card (first play of a session, skippable).
|
||||||
|
--}}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const MSB_STATE = @json($msbState);
|
||||||
|
const VIDEO_ID = @json($video->id);
|
||||||
|
const LS_KEY = 'msb_prefs';
|
||||||
|
|
||||||
|
const RUN = function () {
|
||||||
|
const root = document.getElementById('msbRoot');
|
||||||
|
if (!root) return;
|
||||||
|
const $ = (name) => document.querySelector('[data-msb="' + name + '"]');
|
||||||
|
const player = () => document.getElementById('videoPlayer');
|
||||||
|
|
||||||
|
// ── Preferences (per-user, persisted) ─────────────────────────────
|
||||||
|
const APP_DEFAULTS = { scorebar: true, feed: true, info: true, timeline: true, clubs: true, flags: true, penalties: false };
|
||||||
|
const VIDEO_DEFAULTS = MSB_STATE.defaults || {};
|
||||||
|
const DEFAULTS = Object.assign({}, APP_DEFAULTS, VIDEO_DEFAULTS);
|
||||||
|
|
||||||
|
let prefs;
|
||||||
|
try { prefs = Object.assign({}, DEFAULTS, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
|
||||||
|
catch (e) { prefs = { ...DEFAULTS }; }
|
||||||
|
|
||||||
|
const savePrefs = () => { try { localStorage.setItem(LS_KEY, JSON.stringify(prefs)); } catch (e) {} };
|
||||||
|
const applyPrefsClasses = () => {
|
||||||
|
for (const key of Object.keys(APP_DEFAULTS)) {
|
||||||
|
document.body.classList.toggle('msb-off-' + key, !prefs[key]);
|
||||||
|
}
|
||||||
|
// Signal to the scrim which parts are visible
|
||||||
|
root.classList.toggle('msb-has-scorebar', !!prefs.scorebar);
|
||||||
|
root.classList.toggle('msb-has-feed', !!prefs.feed);
|
||||||
|
root.classList.toggle('msb-has-info', !!prefs.info);
|
||||||
|
root.classList.toggle('msb-has-timeline', !!prefs.timeline);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Corner-label localisation ─────────────────────────────────────
|
||||||
|
const CORNER_LABELS = {
|
||||||
|
redblue: { red: 'Red', blue: 'Blue', disc: '' },
|
||||||
|
karate: { red: 'Aka', blue: 'Ao', disc: 'Karate Kumite' },
|
||||||
|
taekwondo: { red: 'Hong', blue: 'Chung', disc: 'Taekwondo Kyorugi' },
|
||||||
|
};
|
||||||
|
function labelSet() {
|
||||||
|
const sport = (MSB_STATE.sport || 'karate').toLowerCase();
|
||||||
|
if (sport === 'taekwondo' || sport === 'taekwondo_kyorugi') return CORNER_LABELS.taekwondo;
|
||||||
|
if (sport === 'karate' || sport === 'karate_kumite') return CORNER_LABELS.karate;
|
||||||
|
return CORNER_LABELS.redblue;
|
||||||
|
}
|
||||||
|
function pointLabel(p) {
|
||||||
|
const sport = (MSB_STATE.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';
|
||||||
|
}
|
||||||
|
const colorFor = (side) => side === 'red' ? '#ff6a5e' : '#6aa6ff';
|
||||||
|
|
||||||
|
// ── Fixed-value paints (only once per load) ───────────────────────
|
||||||
|
function paintFixed() {
|
||||||
|
const labels = labelSet();
|
||||||
|
const setText = (name, val, hideIfEmpty = true) => {
|
||||||
|
const el = $(name); if (!el) return;
|
||||||
|
el.textContent = val || '';
|
||||||
|
if (hideIfEmpty) el.toggleAttribute('data-msb-empty', !val);
|
||||||
|
};
|
||||||
|
// Replace the placeholder "CLUB LOGO" text with a real <img>
|
||||||
|
// (or leave the placeholder if no image). Never touches inline styles.
|
||||||
|
const setImage = (name, path) => {
|
||||||
|
const el = $(name); if (!el) return;
|
||||||
|
if (!path) return;
|
||||||
|
el.textContent = '';
|
||||||
|
el.style.background = 'rgba(0,0,0,.32)';
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = path; img.alt = '';
|
||||||
|
img.style.cssText = 'width:100%;height:100%;object-fit:contain;display:block';
|
||||||
|
el.appendChild(img);
|
||||||
|
};
|
||||||
|
// Swap the placeholder "FLAG" box for a real flag image by pointing
|
||||||
|
// its background-image at the self-hosted flag-icons SVG.
|
||||||
|
const setFlag = (name, iso2) => {
|
||||||
|
const el = $(name); if (!el) return;
|
||||||
|
const code = (iso2 || '').toLowerCase();
|
||||||
|
if (!/^[a-z]{2}$/.test(code) || code === 'xx') return;
|
||||||
|
el.textContent = '';
|
||||||
|
el.style.background = '#000';
|
||||||
|
el.style.backgroundImage = 'url({{ asset('vendor/flag-icons/flags/4x3') }}/' + code + '.svg)';
|
||||||
|
el.style.backgroundSize = 'cover';
|
||||||
|
el.style.backgroundPosition = 'center';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Identity — default to karate discipline label when sport unknown
|
||||||
|
setText('disc', labels.disc || MSB_STATE.discipline || '');
|
||||||
|
setText('sub', MSB_STATE.subtitle || '');
|
||||||
|
// Hide the whole info block (rule + text) when both rows are empty
|
||||||
|
const infoBlock = document.querySelector('[data-msb="info"]');
|
||||||
|
if (infoBlock) {
|
||||||
|
const empty = !$('disc').textContent && !$('sub').textContent;
|
||||||
|
infoBlock.style.display = empty ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Athletes — brief §2: "no athlete name → show the corner label
|
||||||
|
// ('RED' / 'BLUE') in the name slot" (always uppercase, not the
|
||||||
|
// sport-flavoured AKA/AO which would collide with the corner label
|
||||||
|
// that already sits above the numeral).
|
||||||
|
setText('redName', MSB_STATE.red.name || 'RED');
|
||||||
|
setText('blueName', MSB_STATE.blue.name || 'BLUE');
|
||||||
|
setText('redClubName', MSB_STATE.red.club || '');
|
||||||
|
setText('blueClubName', MSB_STATE.blue.club || '');
|
||||||
|
setText('redCorner', labels.red.toUpperCase(), false);
|
||||||
|
setText('blueCorner', labels.blue.toUpperCase(), false);
|
||||||
|
|
||||||
|
setFlag('redFlag', MSB_STATE.red.flag);
|
||||||
|
setFlag('blueFlag', MSB_STATE.blue.flag);
|
||||||
|
setImage('redClub', MSB_STATE.red.club_logo);
|
||||||
|
setImage('blueClub', MSB_STATE.blue.club_logo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playback-time-driven render (memoised) ────────────────────────
|
||||||
|
function currentRound(t) {
|
||||||
|
const rs = MSB_STATE.rounds || [];
|
||||||
|
if (!rs.length) return { n: 1, name: '', start: 0 };
|
||||||
|
let cur = rs[0];
|
||||||
|
for (const r of rs) { if (t >= (r.start || 0)) cur = r; }
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
function pointsUpTo(t) {
|
||||||
|
return (MSB_STATE.points || []).filter(p => p.t <= t + 0.001);
|
||||||
|
}
|
||||||
|
function fmtClock(sec) {
|
||||||
|
sec = Math.max(0, Math.floor(sec || 0));
|
||||||
|
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
||||||
|
}
|
||||||
|
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||||
|
|
||||||
|
let lastRound = '', lastClock = '', lastScore = '', lastFeed = '', lastTl = '';
|
||||||
|
function updateForTime(t) {
|
||||||
|
const round = currentRound(t);
|
||||||
|
const totalRounds = Math.max(1, (MSB_STATE.rounds || []).length);
|
||||||
|
const sport = (MSB_STATE.sport || '').toLowerCase();
|
||||||
|
|
||||||
|
// Round label
|
||||||
|
let roundText = round.name || '';
|
||||||
|
if (sport.startsWith('taekwondo')) roundText = 'Round ' + round.n + ' / ' + totalRounds;
|
||||||
|
else if (sport.startsWith('karate')) roundText = roundText || 'Kumite';
|
||||||
|
if (roundText !== lastRound) { $('roundLabel').textContent = roundText; lastRound = roundText; }
|
||||||
|
|
||||||
|
// Clock = time since current round's start (or since t=0 if no round starts)
|
||||||
|
const inRound = Math.max(0, t - (round.start || 0));
|
||||||
|
const clockStr = fmtClock(inRound);
|
||||||
|
if (clockStr !== lastClock) { $('clock').textContent = clockStr; lastClock = clockStr; }
|
||||||
|
|
||||||
|
const seen = pointsUpTo(t);
|
||||||
|
const last = seen[seen.length - 1];
|
||||||
|
const red = last ? last.sr : 0;
|
||||||
|
const blue = last ? last.sb : 0;
|
||||||
|
const sSig = red + '|' + blue;
|
||||||
|
if (sSig !== lastScore) {
|
||||||
|
$('redScore').textContent = red;
|
||||||
|
$('blueScore').textContent = blue;
|
||||||
|
lastScore = sSig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline segments across the duration — writes into the design's
|
||||||
|
// verbatim inline-styled container (14 pre-rendered <div>s).
|
||||||
|
const SEG_COUNT = Math.max(14, (MSB_STATE.points || []).length);
|
||||||
|
const tlSig = SEG_COUNT + '|' + seen.map(p => p.side[0]).join('');
|
||||||
|
if (tlSig !== lastTl) {
|
||||||
|
const tl = $('timeline');
|
||||||
|
if (tl) {
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < SEG_COUNT; i++) {
|
||||||
|
const p = seen[i];
|
||||||
|
const color = p ? colorFor(p.side) : '#2a2724';
|
||||||
|
html += '<div style="flex:1;background:' + color + '"></div>';
|
||||||
|
}
|
||||||
|
tl.innerHTML = html;
|
||||||
|
}
|
||||||
|
lastTl = tlSig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticker: last 4, newest first
|
||||||
|
const last4 = seen.slice(-4).reverse();
|
||||||
|
const fSig = last4.map(p => p.t + ':' + p.side + ':' + p.pts).join(',');
|
||||||
|
if (fSig !== lastFeed) {
|
||||||
|
const feed = $('feed');
|
||||||
|
if (feed) {
|
||||||
|
feed.innerHTML = last4.map(p => {
|
||||||
|
const col = colorFor(p.side);
|
||||||
|
return '<div class="entry msb-new" style="border-color:' + col + '">'
|
||||||
|
+ '<span class="ts">' + fmtClock(p.t) + '</span>'
|
||||||
|
+ '<span class="name">' + esc(pointLabel(p)) + '</span>'
|
||||||
|
+ '<span class="pts" style="color:' + col + '">+' + p.pts + '</span>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
lastFeed = fSig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gear-menu injection ───────────────────────────────────────────
|
||||||
|
// Custom row markup (per design): compact label + 26×14 track + 10 px
|
||||||
|
// bone knob. Nothing on the platform side changes.
|
||||||
|
function injectGearRows() {
|
||||||
|
const panel = document.getElementById('ytpSettingsPanel');
|
||||||
|
if (!panel) return false;
|
||||||
|
if (panel.dataset.msbInjected) return true;
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'msb-gear-header';
|
||||||
|
header.textContent = 'Scoreboard';
|
||||||
|
panel.appendChild(header);
|
||||||
|
|
||||||
|
const ROWS = [
|
||||||
|
['scorebar', 'Score bar'],
|
||||||
|
['feed', 'Live scoring feed'],
|
||||||
|
['info', 'Match info'],
|
||||||
|
['timeline', 'Point timeline'],
|
||||||
|
['clubs', 'Club logos'],
|
||||||
|
['flags', 'Country flags'],
|
||||||
|
['penalties', 'Penalties'],
|
||||||
|
];
|
||||||
|
for (const [key, label] of ROWS) {
|
||||||
|
const row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'msb-gear-row' + (prefs[key] ? ' on' : '');
|
||||||
|
row.dataset.msbToggle = key;
|
||||||
|
row.innerHTML =
|
||||||
|
'<span>' + label + '</span>' +
|
||||||
|
'<span class="msb-gear-track"><span class="msb-gear-knob"></span></span>';
|
||||||
|
row.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
prefs[key] = !prefs[key];
|
||||||
|
row.classList.toggle('on', prefs[key]);
|
||||||
|
savePrefs(); applyPrefsClasses();
|
||||||
|
});
|
||||||
|
panel.appendChild(row);
|
||||||
|
}
|
||||||
|
panel.dataset.msbInjected = '1';
|
||||||
|
|
||||||
|
// Design: gear icon turns red while the menu is open.
|
||||||
|
const sync = () => document.body.classList.toggle('msb-menu-open', panel.classList.contains('open'));
|
||||||
|
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
sync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function tryInject(attemptsLeft) {
|
||||||
|
if (injectGearRows()) return;
|
||||||
|
if (attemptsLeft > 0) setTimeout(() => tryInject(attemptsLeft - 1), 200);
|
||||||
|
}
|
||||||
|
tryInject(30);
|
||||||
|
|
||||||
|
// ── Overlay scaling to a fixed 1150 px design canvas ──────────────
|
||||||
|
// Keeps the score bar, ticker, and identity at their pixel-perfect
|
||||||
|
// proportions no matter how wide the actual player is.
|
||||||
|
function initScale() {
|
||||||
|
// Scale against #videoContainer (the .ytp inner box) — that's the
|
||||||
|
// 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');
|
||||||
|
if (!wrap || !layer) return;
|
||||||
|
const DESIGN_W = 1150;
|
||||||
|
const update = () => {
|
||||||
|
const w = wrap.clientWidth || 1;
|
||||||
|
const h = wrap.clientHeight || 1;
|
||||||
|
const s = w / DESIGN_W;
|
||||||
|
layer.style.transform = 'scale(' + s + ')';
|
||||||
|
layer.style.height = (h / s) + 'px';
|
||||||
|
// "small player" auto-hides identity + feed. Skip it in
|
||||||
|
// fullscreen — the highlights drawer squeezes the container
|
||||||
|
// width but the user still wants to see the live feed next
|
||||||
|
// 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();
|
||||||
|
if (typeof ResizeObserver === 'function') {
|
||||||
|
new ResizeObserver(update).observe(wrap);
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire everything ───────────────────────────────────────────────
|
||||||
|
applyPrefsClasses();
|
||||||
|
paintFixed();
|
||||||
|
initScale();
|
||||||
|
|
||||||
|
function attachPlayer() {
|
||||||
|
const v = player();
|
||||||
|
if (!v) { setTimeout(attachPlayer, 250); return; }
|
||||||
|
const tick = () => updateForTime(v.currentTime || 0);
|
||||||
|
v.addEventListener('timeupdate', tick);
|
||||||
|
v.addEventListener('seeked', tick);
|
||||||
|
v.addEventListener('loadedmetadata', tick);
|
||||||
|
v.addEventListener('ratechange', tick);
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
attachPlayer();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', RUN);
|
||||||
|
} else {
|
||||||
|
setTimeout(RUN, 0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
{{--
|
||||||
|
Karate/Taekwondo match scoreboard overlay — all CSS in one place.
|
||||||
|
Class prefix: msb- (match scoreboard). Nothing else in the codebase uses it.
|
||||||
|
Everything lives inside #msbRoot which is `position:absolute; inset:0; pointer-events:none`
|
||||||
|
and is NOT z-indexed on purpose, so #ytpControls (emitted after the slot) paints on top.
|
||||||
|
--}}
|
||||||
|
<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>
|
||||||
|
/* ────────────────────────────── keyframes ────────────────────────────── */
|
||||||
|
@keyframes msbRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes msbPulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
|
||||||
|
|
||||||
|
/* ────────────────────────────── root layer ─────────────────────────────
|
||||||
|
No z-index on the root: source order inside #ytpWrap places the video
|
||||||
|
controls AFTER the overlay slot, so they paint on top and stay clickable.
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
|
.msb-root {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
color: #efe9e0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.msb-root * { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fixed 1150 px design canvas. `msb-scale` is positioned at (0, 0), sized
|
||||||
|
* to 1150 px wide, and JS-scaled by `transform: scale(playerWidth / 1150)`
|
||||||
|
* so every child element (positioned in that 1150 px coord space) shrinks
|
||||||
|
* or grows proportionally with the player. Height is set at runtime.
|
||||||
|
*/
|
||||||
|
.msb-scale {
|
||||||
|
position: absolute; top: 0; left: 0;
|
||||||
|
width: 1150px;
|
||||||
|
transform-origin: top left;
|
||||||
|
/* transform + height set by JS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrim disabled — every scoreboard text element has its own text-shadow
|
||||||
|
for legibility, so we don't tint the video at all. */
|
||||||
|
.msb-scrim { display: none; }
|
||||||
|
|
||||||
|
/* ────────────────────────────── match info (top-left) ────────────────── */
|
||||||
|
.msb-info { position: absolute; top: 22px; left: 26px; display: flex; align-items: stretch; gap: 12px; }
|
||||||
|
.msb-info .rule { width: 4px; background: linear-gradient(#e8534a, #7c1d18); }
|
||||||
|
.msb-info .txt { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.msb-info .disc {
|
||||||
|
font-family: 'Zen Old Mincho', serif; font-size: 15px; letter-spacing: .34em;
|
||||||
|
color: #efe9e0; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.msb-info .sub {
|
||||||
|
font-size: 13px; letter-spacing: .28em; color: #cfc9c1;
|
||||||
|
text-transform: uppercase; text-shadow: 0 1px 6px rgba(0,0,0,.9);
|
||||||
|
}
|
||||||
|
/* Auto-hide row when its text is empty (server empties data-msb-empty) */
|
||||||
|
.msb-info [data-msb-empty] { display: none; }
|
||||||
|
|
||||||
|
/* ────────────────────────────── live scoring ticker (top-right) ──────── */
|
||||||
|
/* top:56px clears the existing Highlights toggle chip that sits at top:8/right:8 (h=24) */
|
||||||
|
.msb-feed { position: absolute; top: 56px; right: 24px; width: 262px; display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.msb-feed .hdr {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
.msb-feed .hdr .dot {
|
||||||
|
width: 7px; height: 7px; border-radius: 50%;
|
||||||
|
background: #e8534a; animation: msbPulse 1.6s infinite;
|
||||||
|
}
|
||||||
|
.msb-feed .list { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.msb-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;
|
||||||
|
}
|
||||||
|
.msb-feed .entry.msb-new { animation: msbRiseIn .45s ease both; }
|
||||||
|
.msb-feed .entry .ts { font-size: 12px; line-height: 1; letter-spacing: .16em; color: #79736c; }
|
||||||
|
.msb-feed .entry .name { font-size: 17px; line-height: 1; letter-spacing: .12em; font-weight: 700; color: #efe9e0; text-transform: uppercase; }
|
||||||
|
.msb-feed .entry .pts { font-family: 'Zen Old Mincho', serif; font-size: 19px; line-height: 1; font-weight: 700; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Score bar + timeline visuals live entirely as inline styles on the verbatim
|
||||||
|
* markup from drafts/score-bar.html. Nothing here targets those elements
|
||||||
|
* except the visibility toggles below (via data-msb-part), so the design's
|
||||||
|
* exact pixel measurements are never overridden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ────────────────────────────── visibility toggles ──────────────────── */
|
||||||
|
/* Body-level classes hide any part with the matching data-msb-part attr
|
||||||
|
without touching its inline styles. Master-off hides everything. */
|
||||||
|
body.msb-off-scorebar [data-msb-part="scorebar"],
|
||||||
|
body.msb-off-timeline [data-msb-part="timeline"],
|
||||||
|
body.msb-off-info [data-msb-part="info"],
|
||||||
|
body.msb-off-feed [data-msb-part="feed"],
|
||||||
|
body.msb-off-clubs [data-msb-part="clubs"],
|
||||||
|
body.msb-off-flags [data-msb-part="flags"],
|
||||||
|
body.msb-off-penalties [data-msb-part="penalties"] { display: none !important; }
|
||||||
|
|
||||||
|
/* ────────────────────────────── responsive ──────────────────────────── */
|
||||||
|
/* Below ~700 px actual player width the identity + ticker disappear
|
||||||
|
(brief §7). The `msb-small` class is toggled by JS off ResizeObserver. */
|
||||||
|
body.msb-small .msb-info,
|
||||||
|
body.msb-small .msb-feed { display: none !important; }
|
||||||
|
|
||||||
|
/* ────────────────────────────── injected gear rows ──────────────────── */
|
||||||
|
/* Matches the design prototype's custom look (26×14 track, 10 px bone knob),
|
||||||
|
scoped so it never touches the platform's other rows. */
|
||||||
|
.msb-gear-header {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 11px; letter-spacing: .3em; color: #8f8a83; text-transform: uppercase;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.1); margin-top: 4px;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
}
|
||||||
|
.msb-gear-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px; padding: 9px 14px;
|
||||||
|
background: transparent; border: 0; width: 100%; cursor: pointer;
|
||||||
|
font-family: 'Barlow Condensed', sans-serif;
|
||||||
|
font-size: 14px; letter-spacing: .14em; color: #efe9e0;
|
||||||
|
text-transform: uppercase; text-align: left;
|
||||||
|
transition: background .15s ease;
|
||||||
|
}
|
||||||
|
.msb-gear-row:hover { background: rgba(255,255,255,.07); }
|
||||||
|
.msb-gear-track {
|
||||||
|
position: relative; width: 26px; height: 14px; flex: none;
|
||||||
|
background: #3a3734; transition: background .18s ease;
|
||||||
|
}
|
||||||
|
.msb-gear-knob {
|
||||||
|
position: absolute; top: 2px; left: 2px;
|
||||||
|
width: 10px; height: 10px; background: #efe9e0;
|
||||||
|
transition: left .18s ease;
|
||||||
|
}
|
||||||
|
.msb-gear-row.on .msb-gear-track { background: #e8534a; }
|
||||||
|
.msb-gear-row.on .msb-gear-knob { left: 14px; }
|
||||||
|
/* Turn the gear icon red while the settings panel is open — matches design */
|
||||||
|
body.msb-menu-open #ytpSettingsBtn svg { fill: #e8534a; }
|
||||||
|
</style>
|
||||||
80
resources/views/videos/partials/match/vs-og.blade.php
Normal file
80
resources/views/videos/partials/match/vs-og.blade.php
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
{{-- ══════════════════════════════════════════════════════════════════════
|
||||||
|
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>
|
||||||
561
resources/views/videos/partials/match/vs/index.blade.php
Normal file
561
resources/views/videos/partials/match/vs/index.blade.php
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
{{-- ══════════════════════════════════════════════════════════════════════
|
||||||
|
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
|
||||||
@ -366,6 +366,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -356,6 +356,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
|||||||
38
resources/views/videos/vs-preview.blade.php
Normal file
38
resources/views/videos/vs-preview.blade.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{{-- Standalone preview page for the VS intro overlay. Renders the same
|
||||||
|
partial that appears over the match player, in a fixed 16:9 stage
|
||||||
|
centered on a blank page, with no video element behind it. --}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>VS Preview — {{ $video->title }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ asset('vendor/flag-icons/css/flag-icons.min.css') }}">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; background: #050507; height: 100%; overflow: hidden; }
|
||||||
|
body { display: flex; align-items: center; justify-content: center; font-family: system-ui, sans-serif; }
|
||||||
|
.vs-preview-stage {
|
||||||
|
position: relative;
|
||||||
|
width: min(100vw, calc(100vh * 16 / 9));
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
box-shadow: 0 30px 80px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
/* The partial's #vsScreen uses position:absolute + inset:0, so it fills
|
||||||
|
the stage exactly like it does inside .ytp on the real player. */
|
||||||
|
#videoPlayer { display: none; } /* partial's script looks up this id, keep it inert */
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{-- The partial's script pauses/plays #videoPlayer to hold playback
|
||||||
|
until the countdown ends. On the preview page there is no source
|
||||||
|
to play, but the countdown itself is real-time so the overlay
|
||||||
|
still ticks 5 → 0 and dismisses correctly. --}}
|
||||||
|
<video id="videoPlayer" preload="none"></video>
|
||||||
|
<div class="vs-preview-stage">
|
||||||
|
@php $hd = $video->sportsMatch->headerData(); @endphp
|
||||||
|
@include('videos.partials.match.vs.index')
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -40,6 +40,10 @@ Route::get('/videos/{numericId}', function ($numericId) {
|
|||||||
|
|
||||||
Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken');
|
Route::get('/videos/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');
|
||||||
@ -57,6 +61,35 @@ 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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user