Compare commits

..

11 Commits

Author SHA1 Message Date
ghassan
20c8fdcdf3 Video pages: stack Up Next below the video under 1300px
Between 992px and 1300px the left nav (240px) and the 300px side rail left
the video column under ~700px. Up Next titles collapsed into a ~124px
channel and the match About card broke its VS row apart, so a window that
was not full-screen lost information instead of reflowing.

Add one additive @media (max-width: 1300px) block per video type that fires
the SAME stacking the <=991px layout already did, just earlier: layout goes
column, video section and sidebar go full width. Match also collapses
fighters-row to 1fr and stacks the event header.

Nothing is removed. >=1301px is byte-for-byte the old desktop layout, and
the <=991px block still owns the phone treatment (thumb-on-top cards, match
review view, bottom sheet).

Verified by headless render at 1600/1360/1200/1050/960/820/700/420: no
horizontal overflow at any width, desktop unchanged, and a music video still
plays (readyState 4, stream advancing) per the match-type-only rule.
Generic could not be checked visually - there are no generic videos in the
database - but its block is identical to music's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:22:16 +03:00
ghassan
9f82d3cf31 Match capture strips: freeze video on every scrubber interaction
The point-capture and coach-review strips have a forcePause helper that
pauses the video (and re-pauses 30ms later to beat HLS.js's async
resume) whenever the slider is grabbed or scrubbed. It wasn't being
called from the frame-step buttons or the typed-timestamp commit, so
using the ± nudge to land on an exact moment while the video was
playing would happily keep the video playing and drift past the target.

Expose forcePause on both _pcbCtx and _rcbCtx and call it from:
- pcbNudge / rcbNudge (frame-step buttons)
- commitTimeInput / _rcbCommitTimeInput (typed timestamp on Enter)
- _rcbSetActiveSlot (switching start/end slot re-scrubs the video)

Playback now resumes only on an explicit play press.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-11 13:48:34 +03:00
ghassan
3e18bd2150 Match videos: composed VS-card OG preview + richer share metadata
Sports match share links now show the full VS composition (fighters,
flags, event, weight class, referee/court/match chips) in WhatsApp /
Telegram / Facebook / Twitter previews instead of the raw video frame.

- Browsershot + puppeteer-bundled Chromium screenshots a dedicated
  1200x675 (true 16:9) render of the vs-mini composition. Chrome runs
  through a small wrapper (bin/browsershot-node.sh) that raises the fd
  limit and reads its full env, since PHP-FPM's stripped env otherwise
  makes Chromium fail to launch.
- Render is JPEG @ q85 (~100 KB) instead of PNG (~700 KB) — WhatsApp
  silently drops OG images above roughly 300 KB, which is why music
  thumbnails always showed and match cards didn't.
- SportsMatchController@store/update renders synchronously so the
  picture is guaranteed to exist before the user copies the share URL;
  edits regenerate and old stamped variants are cleaned. The og:image
  URL carries a ?v={updated_at} stamp so scrapers recache on edit.
- accessShare() (/s/{token}) now detects scraper user agents and serves
  the video page HTML inline instead of a 302 — some crawlers don't
  follow redirects when building link previews.
- og:description / twitter:description built from headerData(): flag
  emoji + fighter names (blue vs red), event, weight, match#, court,
  referee, view count, upload date.
- Fixed 3-dot menu on video cards being hidden behind the next card
  (:has(.dropdown-menu.show) promotes the active card above siblings
  and lifts the overflow:hidden clip on .yt-video-info).
- vs-mini: skip the entrance animation on touch devices and expose a
  .vs-mini-static class so the OG render always captures the final
  frame instead of a random mid-animation moment.

Also adds an admin gallery at /admin/og-previews for spot-checking the
generated preview JPEGs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-11 12:34:40 +03:00
ghassan
665bad76bf Match cards: composed title, header stage from match title, equal-sized grid
- SportsMatchController syncs Video.title with SportsMatch.title on
  store/update so the "Match title" field drives the video's display
  title everywhere.
- headerData(): "championship" now prefers event_name (event title)
  and adds a dedicated "stage" key sourced from SportsMatch.title
  (shown between the yellow lines above the weight class in both the
  in-player VS overlay and the vs-mini gallery card).
- Video cards render match titles with flags + corner colours
  ("FINALS – 🇧🇭 Blue vs 🇧🇭 Red (-61 kg)"), matching the video
  page header, and clip long titles to one line.
- Video gallery grid uses repeat(N, minmax(0, 1fr)) + grid-auto-rows:1fr
  so every card thumbnail renders at exactly the same size; hover-preview
  videos use object-fit:cover to stop portrait sources looking smaller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-11 09:14:25 +03:00
ghassan
04d63cccdb VS intro screen (arena design), card VS mini + live scorebar, full country names, sports-match edit fix
Match player + preview
- Rebuild VS intro as a 1920×1080 scaled canvas (arena design from drafts/):
  angled RED/BLUE panels with clip-paths, slam-in VS with pulse+shine,
  event/stage/weight header, Match/Court/Referee chips, per-fighter
  Record/Rank/Stats chip row. Every placeholder from the artifact is
  always in the DOM; empty ones are hidden by `.vs-nullable:empty`
  and `.vs-nullable-hide` so the layout adapts without losing the
  design's HTML shape. Country codes always render as full country
  names (via new App\Data\Countries::name()).
- Real-time 6s countdown Skip button; video pinned at t=0 while
  the intro runs, released to play at zero or on Skip.
- Standalone preview page at /videos/{video}/vs-preview.

Video cards (home + everywhere)
- Match cards get a `vs-mini` overlay of the same arena design
  (fixed canvas, per-card ResizeObserver, animations replay every
  time the mouse leaves the thumb). On hover the mini explodes
  outward (panels fly out, VS scales+blurs+fades) and the hover
  video plays over the cleared thumb.
- Match cards also render a `scorebar-mini` overlay (verbatim of
  the full-player msb-scale scorebar + msb-feed) that shows
  during hover playback. Live-scoring is wired per-card via a
  data-sbm-state payload of rounds+points; RED/BLUE score, round
  label, per-round clock, and the top-right Live Scoring ticker
  (4 entries, sport-aware Yuko/Waza-ari/Ippon or Punch/Body
  kick/… labels) all update from the mini video's timeupdate.

Bug fixes
- Hover video wasn't playing sound: dropped the `muted` attribute
  I'd added while debugging match previews.
- White flash strobing on the left of match cards: removed the
  `.vs-mini-flash` element (intro-only flourish that fired on
  every mouseleave replay).
- Sports-match modal was showing "Please complete the required
  fields" when saving basic info for match videos that had no
  SportsMatch row yet: the submit gate only checked matchId, not
  the attached videoId; now it correctly skips uploadVideoFirst()
  in both edit and attach-existing-video modes.

Country names project-wide
- New helper App\Data\Countries::name($iso2) → full country name.
- Swapped `?? country` fallbacks to `?? Countries::name(country)`
  in admin dashboard, video-analytics, video-insights payloads
  (VideoController + SuperAdminController), and the VS partials.
  Now user-visible country labels always show "Bahrain" instead
  of "BH", including chart tooltips and modal titles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-10 18:34:31 +03:00
ghassan
5b6bd86d70 Video player: lock frame to strict 16:9 at all times
Remove max-height clamps and override portrait/square/ultrawide/theater
variants so the .ytp-wrap box shape never distorts. Portrait content
letterboxes inside the 16:9 frame via object-fit: contain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-10 13:39:15 +03:00
ghassan
b05ff14b3b Fullscreen highlights drawer: keep scoreboard visible, don't hide feed; scrollable gear menu; remove slowmo picker + scrim tint
- Scoreboard scale observes #videoContainer (the .ytp inner box) instead of
  #ytpWrap so the overlay tracks the squeezed container when the fullscreen
  highlights drawer opens. Score bar + live-scoring feed shift left with the
  video and stay fully visible next to the drawer.

- msb-small (auto-hide identity + feed under 700 px) now skipped while
  document.fullscreenElement is set — the drawer squeeze on smaller viewports
  was dropping the container under 700 and hiding the live feed.

- Player settings panel (#ytpSettingsPanel) is now vertical-scroll capable:
  max-height: calc(100vh - 120px) + overflow-y: auto + slim on-brand
  scrollbar + overscroll-behavior: contain. Top items (Mini player, Playback
  speed) stay reachable when the injected Scoreboard toggles push the
  list past the visible area.

- Removed the slow-mo (.slowmo-picker) 1/4x/1/2x/3/4x selector from the
  match highlights tab bar.

- Disabled the scoreboard scrim (.msb-scrim { display: none }). The two
  linear-gradients were tinting the top and bottom of the video for text
  legibility, but every scoreboard text element already carries its own
  text-shadow — the tint was showing as permanent shadows over portrait
  and landscape videos alike.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-10 00:19:18 +03:00
ghassan
5d038488fb Merge professional-match-overly: match scoreboard overlay + gear-menu toggles + cropper rewrite + sports metadata fixes 2026-08-09 23:49:09 +03:00
ghassan
2b5e480c9a Remove VS screen; fix cropper + sports edit save + headshot/logo mapping
- Remove VS intro from player and thumbnail VS card from match video cards.
  All references (`msb-vs*`, `initVsIntro`, `setVsImg`, `HAS_VS`, `$vs`,
  `$msbThumbVs`, `$hasMatchMeta`) are stripped from scoreboard partials,
  video-card component, and scoreboard script. Related CSS keyframes
  (`msbFadeIn/Out`, `msbDriftA/B`, `msbSweep`) removed.

- Image cropper (resources/views/components/image-cropper.blade.php):
  replace unreliable third-party Cropme integration with a DIY cropper.
  Pan/zoom/rotate wired directly with pointer + wheel events; on save,
  the visible viewport is rendered to an offscreen canvas at the configured
  output-width (with circular clip for `shape="circle"`). Also reparent the
  overlay to <body> on open to escape any transformed Bootstrap-modal
  ancestor's stacking context that clipped it.

- Sports match modal (resources/views/layouts/partials/sports-match-modal.blade.php):
  set fighter photo croppers to 3:4 portrait (285x380) to match the VS
  card's 228x304 frame; club logos + referee stay 1:1 square.

- SportsMatchController::fillFromRequest (line 174): coerce
  `$this->clean($request->input('media', []))` to `[]` when it returns null
  so array_merge doesn't crash under PHP 8's stricter types.

- SportsMatch::headerData(): read fighter headshots + club logos from the
  canonical media.* keys (participant1_photo / participant2_photo /
  club1_logo / club2_logo) that the uploader modal actually saves, with the
  older p1_* / p2_* names kept as a secondary fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-09 23:47:59 +03:00
ghassan
19741f489b Match scoreboard overlay: identity/ticker/scorebar/timeline + VS intro + gear-menu toggles
- Scoreboard overlay driven by video.currentTime (scores/ticker/timeline as pure
  functions of playback time; scrub-back removes points; slow-mo doesn't affect).
- Score bar + timeline + VS intro rendered verbatim from the design handoff
  (drafts/score-bar.html, drafts/vs-screen.html) — only {{ }} data holes filled.
- Fixed 1150 px design canvas scaled to any player size via ResizeObserver so
  the design's pixel-perfect measurements survive at every player width.
- Gear-menu injection adds a "Scoreboard" section with 7 toggle rows (Score bar,
  Live scoring feed, Match info, Point timeline, Club logos, Country flags,
  Penalties) using a custom 26x14 track/knob toggle matching the design.
  Persists per user in localStorage['msb_prefs']; penalties defaults off.
- VS card fades in on first play of a session (skippable by click/tap/key,
  2 s hold then 0.5 s fade); skipped entirely when no athlete metadata.
- SportsMatch::headerData() extended to expose sport, headshots, club logos,
  division, format, match_date and per-video scoreboard defaults from existing
  JSON columns (no schema migration).
- Every missing field hides its element (no "NOT SET" placeholders); athlete
  name falls back to RED/BLUE per brief.
- Pushed .replay-badge down from top:2.5cqi to top:8cqi so it clears the
  new KARATE KUMITE identity block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-09 22:09:04 +03:00
ghassan
ed921a4ccf Merge mobile-view-match-highlights: frame-accurate scrubbing, mobile UX, collapsed action menu 2026-08-09 02:27:48 +03:00
36 changed files with 7173 additions and 156 deletions

View 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;
}
}

View File

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

View File

@ -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 ~35s while the VS PNG renders. That
// wait is intentional: the user typically shares the URL right
// after saving, and WhatsApp/Facebook cache whatever they see
// on first crawl. If the render hasn't finished when the
// crawler hits, we'd be stuck on the thumbnail fallback in
// every future share.
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
}
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'message' => 'Match saved as draft.', 'message' => 'Match saved as draft.',
@ -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);
} }

View File

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

View File

@ -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 (~35s). Called from SportsMatchController
* on save so the file is guaranteed to exist before the user copies
* the share URL critical because WhatsApp/Facebook crawl the URL
* once and cache whatever they get, so if the crawler beats the
* render we end up permanently stuck on the fallback thumbnail.
*
* Chrome won't launch under PHP-FPM's process context (missing HOME,
* stripped env). The artisan CLI subprocess is invoked with a full
* env explicitly restored, which sidesteps the FPM restriction.
*/
public function renderMatchOgSync(Video $video, int $timeoutSeconds = 45): bool
{
// Skip if the PNG for this exact stamp already exists.
$cacheFile = $this->cachedMatchOgPath($video);
if (is_file($cacheFile) && filesize($cacheFile) > 0) {
return true;
}
$php = '/usr/bin/php';
$art = base_path('artisan');
$cmd = sprintf(
'/usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d 2>&1',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id
);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$env = ['HOME' => '/tmp', 'TMPDIR' => '/tmp', 'PATH' => '/usr/bin:/bin'];
$proc = @proc_open($cmd, $descriptors, $pipes, '/tmp', $env);
if (! is_resource($proc)) {
\Log::warning('renderMatchOgSync: proc_open failed', ['video' => $video->id]);
return false;
}
stream_set_blocking($pipes[1], false);
$deadline = microtime(true) + $timeoutSeconds;
while (microtime(true) < $deadline) {
$status = proc_get_status($proc);
if (! $status['running']) break;
usleep(200000);
}
$out = @stream_get_contents($pipes[1]);
@fclose($pipes[1]);
@fclose($pipes[2]);
$exit = proc_close($proc);
if (! is_file($cacheFile)) {
\Log::warning('renderMatchOgSync: render finished without a PNG', [
'video' => $video->id,
'exit' => $exit,
'output' => $out,
]);
return false;
}
return true;
}
/**
* Fire the artisan renderer in a detached background process so this
* request can respond immediately with the fallback thumbnail. Used
* as a last-resort backfill when ogImage is hit and no PNG exists
* (e.g. matches that pre-date this feature).
*/
public function dispatchMatchOgRender(Video $video): void
{
// Skip if a render was fired for this stamp very recently — the
// marker file avoids stampeding multiple detached processes for
// repeated hits before the first one finishes.
$marker = $this->cachedMatchOgPath($video) . '.rendering';
if (is_file($marker) && (time() - filemtime($marker)) < 60) {
return;
}
@mkdir(dirname($marker), 0775, true);
@touch($marker);
// PHP_BINARY under FPM is /usr/sbin/php-fpm, which won't run
// artisan — must use the CLI binary explicitly.
$php = '/usr/bin/php';
$art = base_path('artisan');
$log = storage_path('logs/og-render.log');
// `setsid` detaches the child into a new session so it survives
// FPM's request teardown. `< /dev/null` closes stdin so bash
// treats it as fully backgrounded. `env HOME=/tmp` is mandatory:
// FPM clears the environment (clear_env = yes), the artisan
// command inherits that empty env, and Puppeteer/Chromium then
// fail with "Failed to launch the browser process: Code: null"
// because they can't resolve a user-data directory without HOME.
$cmd = sprintf(
'setsid /usr/bin/env HOME=/tmp TMPDIR=/tmp PATH=/usr/bin:/bin %s %s og:render-match %d >> %s 2>&1 < /dev/null &',
escapeshellcmd($php),
escapeshellarg($art),
(int) $video->id,
escapeshellarg($log)
);
@exec($cmd);
}
/**
* Render (or return the cached) 1200×630 PNG of the sports-match VS
* composition. Cached under data/app/og-cache/match-{id}-{stamp}.png
* so a match edit (which bumps updated_at) automatically produces a
* fresh URL for social recrawlers.
*/
public function renderMatchOgImage(Video $video): ?string
{
$cacheFile = $this->cachedMatchOgPath($video);
$cacheDir = dirname($cacheFile);
if (is_file($cacheFile)) {
return @file_get_contents($cacheFile) ?: null;
}
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
// Purge older stamped variants for this match so the cache dir
// doesn't grow unbounded across edits. Includes the legacy .png
// extension in case any old files are still around.
foreach (glob($cacheDir . "/match-{$video->id}-*.{jpg,png}", GLOB_BRACE) ?: [] as $old) {
if ($old !== $cacheFile) @unlink($old);
}
@unlink($cacheFile . '.rendering');
try {
// Rebuild the URL as plain http on the app's own hostname so
// Chromium can reach it via loopback. --host-resolver-rules
// below points that hostname at 127.0.0.1:80 (nginx). This
// sidesteps Cloudflare (origin isn't reachable from itself
// over its public hostname) without needing a local TLS cert.
// Hit nginx over loopback. server_name is `_` (catch-all) so
// Host header doesn't matter. Chromium can't reach the origin
// over its public hostname (Cloudflare-fronted) and mapping
// the hostname via --host-resolver-rules trips ERR_BLOCKED_BY_CLIENT
// on public TLDs. vsPreview() forces http on generated URLs
// so all subresources (flag CSS, media thumbnails, headshots)
// also resolve to this loopback origin.
$url = 'http://127.0.0.1' . route('videos.vsOgFrame', $video, false);
$chrome = base_path('data/puppeteer-cache/chrome');
$binary = null;
if (is_dir($chrome)) {
$matches = glob($chrome . '/linux-*/chrome-linux64/chrome');
if ($matches) { $binary = $matches[0]; }
}
// PHP-FPM runs without a HOME env var; Chromium's user-data-dir
// resolution fails without one and the browser exits before
// Puppeteer can attach. /tmp is writable by www-data and gets
// cleaned periodically, which is fine for a screenshot job.
$shot = \Spatie\Browsershot\Browsershot::url($url)
->windowSize(1200, 675)
->deviceScaleFactor(1)
->waitUntilNetworkIdle()
// Output JPEG @ q85 (~150 KB) instead of PNG (~700 KB).
// WhatsApp silently drops OG images above roughly 300 KB.
->setScreenshotType('jpeg', 85)
->setNpmBinary('/usr/bin/npm')
// PHP-FPM starts with a stripped env (clear_env = yes) so
// Chromium sees no HOME/TMPDIR/PATH. Without HOME the
// user-data-dir can't be created; without TMPDIR crashpad's
// shmem sockets can't bind; without PATH Chromium can't
// fork its helper processes. Pass all three explicitly.
->setEnvironmentOptions([
'HOME' => '/tmp',
'TMPDIR' => '/tmp',
'PATH' => '/usr/bin:/bin',
])
// PHP-FPM defaults rlimit_files to 1024; Chromium opens
// way more than that on startup and dies silently
// ("Failed to launch the browser process: Code: null").
// The wrapper script re-execs node with a raised limit
// via prlimit, so we don't have to restart php-fpm to
// bump the pool-wide rlimit_files setting.
->setNodeBinary(base_path('bin/browsershot-node.sh'))
->noSandbox()
->addChromiumArguments([
'disable-gpu',
'hide-scrollbars',
'disable-dev-shm-usage',
// FPM's process context confuses Chromium's zygote —
// crashpad fails to fork with its args and chrome
// exits before puppeteer can attach. Isolating the
// user-data-dir per request + turning off crash
// reporting sidesteps the whole handler chain.
'user-data-dir=/tmp/chrome-og-' . getmypid() . '-' . mt_rand(),
'disable-crash-reporter',
'no-crash-upload',
]);
if ($binary) {
$shot->setChromePath($binary);
}
$shot->save($cacheFile);
} catch (\Throwable $e) {
\Log::warning('vs og render failed', [
'video' => $video->id,
'error' => $e->getMessage(),
]);
return null;
}
return is_file($cacheFile) ? (@file_get_contents($cacheFile) ?: null) : null;
}
} }

View File

@ -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
View 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 "$@"

View File

@ -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
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "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",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

94
drafts/score-bar.html Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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"
} }
} }

View File

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

View File

@ -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 &middot;
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
</div>
</div>
</div>
@endforeach
</div>
@endif
</div>
@endsection

View File

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

View File

@ -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,24 +376,64 @@
.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.png'; var cbName = originalFile ? originalFile.name : 'cropped.jpg';
deliverResult(base64ToFile(base64, cbName)); deliverResult(base64ToFile(base64, cbName));
return; return;
} }
if (isFormMode) { if (isFormMode) {
var fname = originalFile ? originalFile.name : 'cropped.png'; var fname = originalFile ? originalFile.name : 'cropped.jpg';
setOnTargetInput(base64ToFile(base64, fname)); setOnTargetInput(base64ToFile(base64, fname));
window.closeCropperModal(id); window.closeCropperModal(id);
if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success'); if (typeof window.showToast === 'function') window.showToast('Image ready!', 'success');
@ -286,7 +452,6 @@
txt.textContent = 'Crop & Save'; txt.textContent = 'Crop & Save';
}); });
} }
});
}; };
/* ── Upload as-is ── */ /* ── Upload as-is ── */

View File

@ -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 {

View File

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

View File

@ -0,0 +1,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.51.3s animation-delay window).
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
if (hasHover) {
document.addEventListener('mouseleave', (e) => {
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
if (!thumb) return;
const vsm = thumb.querySelector(':scope > .vs-mini');
if (!vsm) return;
vsm.classList.remove('vs-mini-run');
void vsm.offsetWidth;
vsm.classList.add('vs-mini-run');
}, true);
}
})();
</script>
@endonce

View File

@ -41,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)

View File

@ -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; }
} }

View File

@ -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'); })

View File

@ -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) {

View File

@ -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])

View File

@ -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>

View File

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

View File

@ -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>

View 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>

View 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

View File

@ -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;

View File

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

View File

@ -356,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;

View File

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

View File

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