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) <[email protected]>
This commit is contained in:
1 parent
ed921a4ccf
commit
19741f489b
16 files changed
+1779
-15
No files matched your search
@@ -0,0 +1,138 @@
|
||||
@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) !== '');
|
||||
|
||||
/* ── Country display names (from Countries::all()) ───────────────── */
|
||||
$countries = \App\Data\Countries::all();
|
||||
$countryName = function (?string $iso2) use ($countries) {
|
||||
if (!$iso2) return null;
|
||||
$key = strtoupper($iso2);
|
||||
return $countries[$key]['name'] ?? null;
|
||||
};
|
||||
|
||||
/* ── 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'] : [],
|
||||
'vs' => null, // populated below once $vs is built
|
||||
];
|
||||
|
||||
/* ── VS card — only render if there is anything to show ─────────── */
|
||||
$vs = [
|
||||
'event' => $hd['championship'] ?? null,
|
||||
'division' => $hd['division'] ?? null,
|
||||
'category' => $hd['weight_category'] ?? null,
|
||||
'round' => null,
|
||||
'red' => [
|
||||
'name' => $hd['red']['name'] ?? null,
|
||||
'flag' => $hd['red']['flag'] ?? null,
|
||||
'country_name' => $countryName($hd['red']['flag'] ?? null),
|
||||
'club' => $hd['red']['club'] ?? null,
|
||||
'club_logo' => $hd['red']['club_logo'] ?? null,
|
||||
'headshot' => $hd['red']['headshot'] ?? null,
|
||||
],
|
||||
'blue' => [
|
||||
'name' => $hd['blue']['name'] ?? null,
|
||||
'flag' => $hd['blue']['flag'] ?? null,
|
||||
'country_name' => $countryName($hd['blue']['flag'] ?? null),
|
||||
'club' => $hd['blue']['club'] ?? null,
|
||||
'club_logo' => $hd['blue']['club_logo'] ?? null,
|
||||
'headshot' => $hd['blue']['headshot'] ?? null,
|
||||
],
|
||||
'weight_category' => $hd['weight_category'] ?? null,
|
||||
'format_line' => $hd['format'] ?? null,
|
||||
'match_number' => $hd['match_number'] ?? null,
|
||||
'court' => !empty($hd['court']) ? ('Tatami '.$hd['court']) : null,
|
||||
'match_date' => $hd['match_date'] ?? null,
|
||||
'venue_name' => $hd['venue']['name'] ?? null,
|
||||
];
|
||||
|
||||
// Enough metadata for a VS card? Need at least one athlete name or headshot.
|
||||
$hasMatchMeta = ($vs['red']['name'] || $vs['red']['headshot'] ||
|
||||
$vs['blue']['name'] || $vs['blue']['headshot']);
|
||||
|
||||
// Feed image paths (headshot/logo) already routed to media.thumbnail for the
|
||||
// JS to consume — mirror them into $msbState.vs so setVsImg() can use them.
|
||||
$msbState['vs'] = [
|
||||
'red' => [
|
||||
'name' => $vs['red']['name'] ?? null,
|
||||
'flag' => $vs['red']['flag'] ?? null,
|
||||
'club_logo' => !empty($vs['red']['club_logo']) ? route('media.thumbnail', $vs['red']['club_logo']) : null,
|
||||
'headshot' => !empty($vs['red']['headshot']) ? route('media.thumbnail', $vs['red']['headshot']) : null,
|
||||
],
|
||||
'blue' => [
|
||||
'name' => $vs['blue']['name'] ?? null,
|
||||
'flag' => $vs['blue']['flag'] ?? null,
|
||||
'club_logo' => !empty($vs['blue']['club_logo']) ? route('media.thumbnail', $vs['blue']['club_logo']) : null,
|
||||
'headshot' => !empty($vs['blue']['headshot']) ? route('media.thumbnail', $vs['blue']['headshot']) : null,
|
||||
],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@include('videos.partials.match.scoreboard.styles')
|
||||
@include('videos.partials.match.scoreboard.overlay')
|
||||
@if ($hasMatchMeta)
|
||||
@include('videos.partials.match.scoreboard.vs-intro', ['vs' => $vs])
|
||||
@endif
|
||||
@include('videos.partials.match.scoreboard.script', ['msbState' => $msbState, 'hasMatchMeta' => $hasMatchMeta])
|
||||
@@ -0,0 +1,99 @@
|
||||
{{--
|
||||
Overlay markup. The score bar + timeline blocks are the design's exact
|
||||
verbatim inline-styled markup from drafts/score-bar.html — only the
|
||||
{{ }} data holes have been filled and `data-msb` attributes added for
|
||||
the JS. `data-msb-part` attributes let the gear-menu toggles hide any
|
||||
part with CSS `display: none` without touching the inline styles.
|
||||
--}}
|
||||
<div class="msb-root" id="msbRoot" data-video-id="{{ $video->id }}">
|
||||
<div class="msb-scrim"></div>
|
||||
|
||||
{{-- Fixed-width scale layer (1150 px design canvas). JS scales it to
|
||||
fit the actual player, so the score bar keeps its pixel-perfect
|
||||
proportions at any player size. --}}
|
||||
<div class="msb-scale" id="msbScale">
|
||||
|
||||
{{-- Match info (top-left) --}}
|
||||
<div class="msb-info" data-msb="info" data-msb-part="info">
|
||||
<div class="rule"></div>
|
||||
<div class="txt">
|
||||
<div class="disc" data-msb="disc"></div>
|
||||
<div class="sub" data-msb="sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Live scoring feed (top-right) --}}
|
||||
<div class="msb-feed" data-msb="feedWrap" data-msb-part="feed">
|
||||
<div class="hdr"><span class="dot"></span><span>Live scoring</span></div>
|
||||
<div class="list" data-msb="feed"></div>
|
||||
</div>
|
||||
|
||||
{{-- ═══════════════════════════════════════════════════════════════════
|
||||
SCORE BAR — verbatim from drafts/score-bar.html (design handoff).
|
||||
Only {{ }} holes replaced with Blade data + data-msb hooks.
|
||||
═══════════════════════════════════════════════════════════════ --}}
|
||||
<div data-msb-part="scorebar" style="position:absolute;left:0;right:0;bottom:56px;padding:0 26px;display:flex;align-items:stretch;gap:0;height:84px;z-index:2;pointer-events:none">
|
||||
|
||||
{{-- RED / AKA --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(122,26,22,.94), rgba(180,52,44,.9));border-bottom:3px solid #ff6a5e">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div data-msb="redClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span data-msb="redFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span data-msb="redName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['red']['name'] ?: 'RED' }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span data-msb="redClubName" data-msb-part="clubs" style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(255,232,228,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['red']['club'] ?? '' }}</span>
|
||||
<span data-msb="redSenshu" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#ffdf6b;color:#3a2a00;font-weight:700;flex:none">SENSHU</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:5px">
|
||||
<span data-msb="redCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(255,236,232,.7)">AKA</span>
|
||||
<span data-msb="redScore" style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- CENTER: round + clock --}}
|
||||
<div style="width:118px;flex:none;transform:skewX(-9deg);background:rgba(9,9,11,.9);backdrop-filter:blur(8px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;border-bottom:3px solid #2c2a28">
|
||||
<div data-msb="roundLabel" style="transform:skewX(9deg);font-size:11px;letter-spacing:.3em;color:#8f8a83;text-transform:uppercase">Round 1</div>
|
||||
<div data-msb="clock" style="transform:skewX(9deg);font-size:34px;line-height:1;font-weight:800;color:#efe9e0;font-variant-numeric:tabular-nums">0:00</div>
|
||||
<div style="transform:skewX(9deg);display:flex;gap:4px">
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#e8534a"></span>
|
||||
<span style="width:20px;height:3px;background:#3a3734"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- BLUE / AO — exact mirror --}}
|
||||
<div style="flex:1 1 0;min-width:0;transform:skewX(-9deg);overflow:hidden;background:linear-gradient(90deg, rgba(30,72,140,.9), rgba(18,44,92,.94));border-bottom:3px solid #6aa6ff">
|
||||
<div style="transform:skewX(9deg);height:100%;padding:0 16px;display:flex;align-items:center;gap:12px">
|
||||
<div style="width:60px;flex:none;display:flex;flex-direction:column;align-items:flex-start;gap:5px">
|
||||
<span data-msb="blueCorner" style="font-size:11px;letter-spacing:.24em;color:rgba(226,238,255,.7)">AO</span>
|
||||
<span data-msb="blueScore" style="font-family:'Zen Old Mincho',serif;font-size:46px;line-height:.8;font-weight:700;color:#fff;text-shadow:0 6px 20px rgba(0,0,0,.5)">0</span>
|
||||
</div>
|
||||
<div style="flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;align-items:flex-end;text-align:right">
|
||||
<div style="display:flex;align-items:center;gap:10px;min-width:0">
|
||||
<span data-msb="blueName" style="flex:1 1 auto;min-width:0;font-size:25px;line-height:1;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['name'] ?: 'BLUE' }}</span>
|
||||
<span data-msb="blueFlag" data-msb-part="flags" style="width:26px;height:17px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 4px,rgba(255,255,255,.1) 4px 8px);box-shadow:0 0 0 1px rgba(255,255,255,.3);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:7px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;min-width:0">
|
||||
<span data-msb="bluePenalty" data-msb-part="penalties" style="display:none;font-size:10px;letter-spacing:.18em;padding:1px 6px;background:#c8492f;color:#fff;font-weight:700;flex:none">C1</span>
|
||||
<span data-msb="blueClubName" data-msb-part="clubs" style="flex:1 1 auto;min-width:0;font-size:12px;letter-spacing:.03em;color:rgba(226,238,255,.85);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ $msbState['blue']['club'] ?? '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-msb="blueClub" data-msb-part="clubs" style="width:46px;height:46px;flex:none;background:rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.24);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;letter-spacing:.05em;color:rgba(255,255,255,.65);text-align:center;line-height:1.25">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- POINT TIMELINE — one segment per point event, red/blue, #2a2724 when empty --}}
|
||||
<div data-msb="timeline" data-msb-part="timeline" style="position:absolute;left:26px;right:26px;bottom:48px;height:5px;display:flex;gap:2px;z-index:2;pointer-events:none">
|
||||
@for ($i = 0; $i < 14; $i++)
|
||||
<div style="flex:1;background:#2a2724"></div>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
</div>{{-- /.msb-scale --}}
|
||||
</div>
|
||||
@@ -0,0 +1,386 @@
|
||||
{{--
|
||||
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 HAS_VS = @json($hasMatchMeta);
|
||||
const VIDEO_ID = @json($video->id);
|
||||
const LS_KEY = 'msb_prefs';
|
||||
const SS_VS_KEY = 'msb_vs_shown_v' + VIDEO_ID;
|
||||
|
||||
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);
|
||||
|
||||
// ── VS card image slots (photo, crest, flag) ────────────────
|
||||
// If data exists, swap the placeholder for a real image.
|
||||
// If no data, hide the placeholder box entirely (never leave
|
||||
// "FIGHTER PHOTO" / "CLUB LOGO" / "FLAG" text visible in prod).
|
||||
const VS = (MSB_STATE.vs || {});
|
||||
const setVsImg = (name, path, hideWhenEmpty = true) => {
|
||||
const el = $(name); if (!el) return;
|
||||
if (path) {
|
||||
el.textContent = '';
|
||||
el.style.background = '#0d0d10';
|
||||
const img = document.createElement('img');
|
||||
img.src = path; img.alt = '';
|
||||
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block';
|
||||
el.appendChild(img);
|
||||
} else if (hideWhenEmpty) {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
};
|
||||
setVsImg('vsRedPhoto', VS.red && VS.red.headshot);
|
||||
setVsImg('vsBluePhoto', VS.blue && VS.blue.headshot);
|
||||
setVsImg('vsRedCrest', VS.red && VS.red.club_logo);
|
||||
setVsImg('vsBlueCrest', VS.blue && VS.blue.club_logo);
|
||||
setFlag('vsRedFlag', VS.red && VS.red.flag);
|
||||
setFlag('vsBlueFlag', VS.blue && VS.blue.flag);
|
||||
}
|
||||
|
||||
// ── Playback-time-driven render (memoised) ────────────────────────
|
||||
function currentRound(t) {
|
||||
const rs = MSB_STATE.rounds || [];
|
||||
if (!rs.length) return { n: 1, name: '', start: 0 };
|
||||
let cur = rs[0];
|
||||
for (const r of rs) { if (t >= (r.start || 0)) cur = r; }
|
||||
return cur;
|
||||
}
|
||||
function pointsUpTo(t) {
|
||||
return (MSB_STATE.points || []).filter(p => p.t <= t + 0.001);
|
||||
}
|
||||
function fmtClock(sec) {
|
||||
sec = Math.max(0, Math.floor(sec || 0));
|
||||
return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
|
||||
}
|
||||
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
|
||||
let lastRound = '', lastClock = '', lastScore = '', lastFeed = '', lastTl = '';
|
||||
function updateForTime(t) {
|
||||
const round = currentRound(t);
|
||||
const totalRounds = Math.max(1, (MSB_STATE.rounds || []).length);
|
||||
const sport = (MSB_STATE.sport || '').toLowerCase();
|
||||
|
||||
// Round label
|
||||
let roundText = round.name || '';
|
||||
if (sport.startsWith('taekwondo')) roundText = 'Round ' + round.n + ' / ' + totalRounds;
|
||||
else if (sport.startsWith('karate')) roundText = roundText || 'Kumite';
|
||||
if (roundText !== lastRound) { $('roundLabel').textContent = roundText; lastRound = roundText; }
|
||||
|
||||
// Clock = time since current round's start (or since t=0 if no round starts)
|
||||
const inRound = Math.max(0, t - (round.start || 0));
|
||||
const clockStr = fmtClock(inRound);
|
||||
if (clockStr !== lastClock) { $('clock').textContent = clockStr; lastClock = clockStr; }
|
||||
|
||||
const seen = pointsUpTo(t);
|
||||
const last = seen[seen.length - 1];
|
||||
const red = last ? last.sr : 0;
|
||||
const blue = last ? last.sb : 0;
|
||||
const sSig = red + '|' + blue;
|
||||
if (sSig !== lastScore) {
|
||||
$('redScore').textContent = red;
|
||||
$('blueScore').textContent = blue;
|
||||
lastScore = sSig;
|
||||
}
|
||||
|
||||
// Timeline segments across the duration — writes into the design's
|
||||
// verbatim inline-styled container (14 pre-rendered <div>s).
|
||||
const SEG_COUNT = Math.max(14, (MSB_STATE.points || []).length);
|
||||
const tlSig = SEG_COUNT + '|' + seen.map(p => p.side[0]).join('');
|
||||
if (tlSig !== lastTl) {
|
||||
const tl = $('timeline');
|
||||
if (tl) {
|
||||
let html = '';
|
||||
for (let i = 0; i < SEG_COUNT; i++) {
|
||||
const p = seen[i];
|
||||
const color = p ? colorFor(p.side) : '#2a2724';
|
||||
html += '<div style="flex:1;background:' + color + '"></div>';
|
||||
}
|
||||
tl.innerHTML = html;
|
||||
}
|
||||
lastTl = tlSig;
|
||||
}
|
||||
|
||||
// Ticker: last 4, newest first
|
||||
const last4 = seen.slice(-4).reverse();
|
||||
const fSig = last4.map(p => p.t + ':' + p.side + ':' + p.pts).join(',');
|
||||
if (fSig !== lastFeed) {
|
||||
const feed = $('feed');
|
||||
if (feed) {
|
||||
feed.innerHTML = last4.map(p => {
|
||||
const col = colorFor(p.side);
|
||||
return '<div class="entry msb-new" style="border-color:' + col + '">'
|
||||
+ '<span class="ts">' + fmtClock(p.t) + '</span>'
|
||||
+ '<span class="name">' + esc(pointLabel(p)) + '</span>'
|
||||
+ '<span class="pts" style="color:' + col + '">+' + p.pts + '</span>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
lastFeed = fSig;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gear-menu injection ───────────────────────────────────────────
|
||||
// Custom row markup (per design): compact label + 26×14 track + 10 px
|
||||
// bone knob. Nothing on the platform side changes.
|
||||
function injectGearRows() {
|
||||
const panel = document.getElementById('ytpSettingsPanel');
|
||||
if (!panel) return false;
|
||||
if (panel.dataset.msbInjected) return true;
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'msb-gear-header';
|
||||
header.textContent = 'Scoreboard';
|
||||
panel.appendChild(header);
|
||||
|
||||
const ROWS = [
|
||||
['scorebar', 'Score bar'],
|
||||
['feed', 'Live scoring feed'],
|
||||
['info', 'Match info'],
|
||||
['timeline', 'Point timeline'],
|
||||
['clubs', 'Club logos'],
|
||||
['flags', 'Country flags'],
|
||||
['penalties', 'Penalties'],
|
||||
];
|
||||
for (const [key, label] of ROWS) {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'msb-gear-row' + (prefs[key] ? ' on' : '');
|
||||
row.dataset.msbToggle = key;
|
||||
row.innerHTML =
|
||||
'<span>' + label + '</span>' +
|
||||
'<span class="msb-gear-track"><span class="msb-gear-knob"></span></span>';
|
||||
row.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
prefs[key] = !prefs[key];
|
||||
row.classList.toggle('on', prefs[key]);
|
||||
savePrefs(); applyPrefsClasses();
|
||||
});
|
||||
panel.appendChild(row);
|
||||
}
|
||||
panel.dataset.msbInjected = '1';
|
||||
|
||||
// Design: gear icon turns red while the menu is open.
|
||||
const sync = () => document.body.classList.toggle('msb-menu-open', panel.classList.contains('open'));
|
||||
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['class'] });
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
function tryInject(attemptsLeft) {
|
||||
if (injectGearRows()) return;
|
||||
if (attemptsLeft > 0) setTimeout(() => tryInject(attemptsLeft - 1), 200);
|
||||
}
|
||||
tryInject(30);
|
||||
|
||||
// ── VS intro lifecycle ────────────────────────────────────────────
|
||||
function initVsIntro() {
|
||||
if (!HAS_VS) return;
|
||||
const vs = document.getElementById('msbVs');
|
||||
const v = player();
|
||||
if (!vs || !v) return;
|
||||
|
||||
let alreadyShown = false;
|
||||
try { alreadyShown = sessionStorage.getItem(SS_VS_KEY) === '1'; } catch (e) {}
|
||||
if (alreadyShown) return;
|
||||
|
||||
let shown = false;
|
||||
const show = () => {
|
||||
if (shown) return;
|
||||
shown = true;
|
||||
try { sessionStorage.setItem(SS_VS_KEY, '1'); } catch (e) {}
|
||||
vs.hidden = false;
|
||||
vs.dataset.visible = 'true';
|
||||
try { v.pause(); } catch (e) {}
|
||||
|
||||
const dismiss = (playToo = true) => {
|
||||
if (!vs || vs.classList.contains('msb-vs-out')) return;
|
||||
vs.classList.add('msb-vs-out');
|
||||
if (playToo) { try { v.play(); } catch (e) {} }
|
||||
setTimeout(() => { if (vs && vs.parentNode) vs.parentNode.removeChild(vs); }, 520);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
vs.addEventListener('click', () => dismiss(true));
|
||||
vs.addEventListener('touchstart', () => dismiss(true), { passive: true });
|
||||
const onKey = () => dismiss(true);
|
||||
document.addEventListener('keydown', onKey);
|
||||
setTimeout(() => dismiss(true), 2000);
|
||||
};
|
||||
|
||||
// Trigger on the first play attempt (autoplay-friendly).
|
||||
const onPlay = () => { v.removeEventListener('play', onPlay); v.removeEventListener('playing', onPlay); show(); };
|
||||
v.addEventListener('play', onPlay, { once: true });
|
||||
v.addEventListener('playing', onPlay, { once: true });
|
||||
// If autoplay is already firing when we arrive:
|
||||
if (!v.paused && v.currentTime < 0.5) show();
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const wrap = 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';
|
||||
// Toggle the "small player" state once we're below the design
|
||||
// brief's ~700 px breakpoint (in real pixels).
|
||||
document.body.classList.toggle('msb-small', w < 700);
|
||||
};
|
||||
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();
|
||||
initVsIntro();
|
||||
}
|
||||
attachPlayer();
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', RUN);
|
||||
} else {
|
||||
setTimeout(RUN, 0);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,176 @@
|
||||
{{--
|
||||
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; } }
|
||||
@keyframes msbFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes msbFadeOut { from { opacity: 1; } to { opacity: 0; } }
|
||||
@keyframes msbDriftA { 0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(60px, -28px, 0) scale(1.14); } }
|
||||
@keyframes msbDriftB { 0%, 100% { transform: translate3d(0, 0, 0) scale(1.08); }
|
||||
50% { transform: translate3d(-54px, 26px, 0) scale(1); } }
|
||||
@keyframes msbSweep { 0% { transform: translateX(-60%) skewX(-14deg); opacity: 0; }
|
||||
25% { opacity: .5; }
|
||||
60% { opacity: 0; }
|
||||
100% { transform: translateX(160%) skewX(-14deg); opacity: 0; } }
|
||||
|
||||
/* ────────────────────────────── 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 */
|
||||
}
|
||||
|
||||
/* Scrims for legibility */
|
||||
.msb-scrim {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
linear-gradient(to top, rgba(6,6,8,.9) 0%, rgba(6,6,8,.45) 15%, rgba(6,6,8,0) 30%),
|
||||
linear-gradient(to bottom, rgba(6,6,8,.85) 0%, rgba(6,6,8,.4) 14%, rgba(6,6,8,0) 30%);
|
||||
opacity: 0;
|
||||
transition: opacity .3s ease;
|
||||
}
|
||||
/* Only show the scrims when there is at least one overlay part visible */
|
||||
.msb-has-scorebar .msb-scrim,
|
||||
.msb-has-feed .msb-scrim,
|
||||
.msb-has-info .msb-scrim,
|
||||
.msb-has-timeline .msb-scrim { opacity: 1; }
|
||||
|
||||
/* ────────────────────────────── match info (top-left) ────────────────── */
|
||||
.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; }
|
||||
|
||||
/*
|
||||
* VS intro card — visuals are entirely inline styles on the verbatim markup
|
||||
* from drafts/vs-screen.html. Only the outer wrapper + fade in/out lives
|
||||
* here; keyframes (msbDriftA/B, msbSweep, msbFadeIn/Out) are up top.
|
||||
*/
|
||||
.msb-vs {
|
||||
position: absolute; inset: 0; z-index: 12;
|
||||
background: #08080a; overflow: hidden;
|
||||
pointer-events: auto;
|
||||
animation: msbFadeIn .3s ease both;
|
||||
}
|
||||
.msb-vs.msb-vs-out { animation: msbFadeOut .5s ease both; }
|
||||
|
||||
/* ────────────────────────────── 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>
|
||||
@@ -0,0 +1,106 @@
|
||||
{{--
|
||||
VS SCREEN — verbatim markup from drafts/vs-screen.html. Only {{ }} holes
|
||||
filled with Blade data. `data-msb` hooks on the photo, crest, and flag
|
||||
boxes let the script swap in real images at runtime. Missing fields are
|
||||
hidden by inline display:none (never substituted with placeholder text).
|
||||
--}}
|
||||
@php
|
||||
$rc = strtoupper(trim($vs['red']['flag'] ?? ''));
|
||||
$bc = strtoupper(trim($vs['blue']['flag'] ?? ''));
|
||||
|
||||
$eventName = $vs['event'] ?? '';
|
||||
$category = $vs['category'] ?? '';
|
||||
$division = $vs['division'] ?? '';
|
||||
$roundName = $vs['round'] ?? '';
|
||||
$redName = $vs['red']['name'] ?? '';
|
||||
$redCountry = $vs['red']['country_name'] ?? '';
|
||||
$redClub = $vs['red']['club'] ?? '';
|
||||
$blueName = $vs['blue']['name'] ?? '';
|
||||
$blueCountry= $vs['blue']['country_name'] ?? '';
|
||||
$blueClub = $vs['blue']['club'] ?? '';
|
||||
$weightClass= $vs['weight_category'] ?? '';
|
||||
$format = $vs['format_line'] ?? '';
|
||||
$bout = !empty($vs['match_number']) ? ('Bout '.$vs['match_number']) : '';
|
||||
$tatami = $vs['court'] ?? '';
|
||||
$dateVenue = trim(($vs['match_date'] ?? '').' '.($vs['venue_name'] ? '· '.$vs['venue_name'] : ''));
|
||||
|
||||
$hide = fn($v) => (is_string($v) && trim($v) === '') ? 'display:none;' : '';
|
||||
@endphp
|
||||
|
||||
<div class="msb-vs" id="msbVs" hidden data-visible="false">
|
||||
<div style="position:relative;width:100%;height:100%;overflow:hidden;background:#08080a">
|
||||
|
||||
<div style="position:absolute;inset:-12%;width:70%;left:-6%;background:radial-gradient(closest-side, rgba(168,42,34,.62), rgba(8,8,10,0) 72%);animation:msbDriftA 19s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;inset:-12%;width:70%;left:36%;background:radial-gradient(closest-side, rgba(28,64,136,.62), rgba(8,8,10,0) 72%);animation:msbDriftB 23s ease-in-out infinite"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;left:0;width:26%;background:linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.09), rgba(255,255,255,0));animation:msbSweep 11s linear infinite"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;left:0;width:54%;clip-path:polygon(0 0, 100% 0, 78% 100%, 0 100%);background:linear-gradient(120deg, rgba(122,26,22,.5), rgba(8,8,10,0) 76%)"></div>
|
||||
<div style="position:absolute;top:0;bottom:0;right:0;width:54%;clip-path:polygon(22% 0, 100% 0, 100% 100%, 0 100%);background:linear-gradient(300deg, rgba(24,52,110,.5), rgba(8,8,10,0) 76%)"></div>
|
||||
|
||||
{{-- event header --}}
|
||||
<div style="position:absolute;top:34px;left:0;right:0;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-family:'Zen Old Mincho',serif;font-size:24px;letter-spacing:.44em;color:#efe9e0;text-transform:uppercase;text-indent:.44em;{{ $hide($eventName) }}">{{ $eventName }}</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;font-size:12px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase">
|
||||
<span style="{{ $hide($category) }}">{{ $category }}</span>
|
||||
<span style="width:4px;height:4px;background:#e8534a;{{ $hide($category).$hide($division) }}"></span>
|
||||
<span style="{{ $hide($division) }}">{{ $division }}</span>
|
||||
<span style="width:4px;height:4px;background:#6aa6ff;{{ $hide($division).$hide($roundName) }}"></span>
|
||||
<span style="{{ $hide($roundName) }}">{{ $roundName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="position:absolute;inset:96px 0 54px;display:grid;grid-template-columns:1fr 200px 1fr;align-items:start">
|
||||
|
||||
{{-- RED fighter --}}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
|
||||
<div style="position:relative;width:228px;height:304px">
|
||||
<div data-msb="vsRedPhoto" style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div data-msb="vsRedCrest" style="position:absolute;bottom:-26px;right:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #e8534a;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ $redName ?: 'RED' }}</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;{{ $hide($redCountry).($rc ? '' : $hide('')) }}">
|
||||
<span data-msb="vsRedFlag" style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
<span style="font-size:14px;letter-spacing:.3em;color:#d3b3ad;text-transform:uppercase;{{ $hide($redCountry) }}">{{ $redCountry }}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:2px;{{ $hide($redClub) }}">
|
||||
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ $redClub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- centre --}}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding-top:96px">
|
||||
<span style="font-family:'Zen Old Mincho',serif;font-size:88px;line-height:.8;font-weight:700;color:#efe9e0;text-shadow:0 8px 34px rgba(0,0,0,.75)">VS</span>
|
||||
<span style="padding:7px 16px;border:1px solid rgba(255,255,255,.3);font-size:14px;letter-spacing:.32em;color:#efe9e0;text-transform:uppercase;{{ $hide($weightClass) }}">{{ $weightClass }}</span>
|
||||
<span style="font-size:11px;letter-spacing:.34em;color:#8f8a83;text-transform:uppercase;{{ $hide($format) }}">{{ $format }}</span>
|
||||
</div>
|
||||
|
||||
{{-- BLUE fighter — mirror --}}
|
||||
<div style="display:flex;flex-direction:column;align-items:center;padding:34px 24px 0;min-width:0">
|
||||
<div style="position:relative;width:228px;height:304px">
|
||||
<div data-msb="vsBluePhoto" style="position:absolute;inset:0;background:repeating-linear-gradient(135deg,rgba(255,255,255,.1) 0 6px,rgba(255,255,255,.03) 6px 12px);border:1px solid rgba(255,255,255,.16);display:flex;align-items:flex-end;justify-content:center;padding-bottom:12px;font-family:ui-monospace,monospace;font-size:10px;letter-spacing:.1em;color:rgba(255,255,255,.6)">FIGHTER PHOTO</div>
|
||||
<div data-msb="vsBlueCrest" style="position:absolute;bottom:-26px;left:-26px;width:104px;height:104px;border-radius:50%;background:#0d0d10;border:1px solid rgba(255,255,255,.28);box-shadow:0 10px 30px rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:9px;letter-spacing:.06em;color:rgba(255,255,255,.7);text-align:center;line-height:1.35">CLUB<br>LOGO</div>
|
||||
</div>
|
||||
<div style="margin-top:34px;padding-top:12px;border-top:3px solid #6aa6ff;display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<div style="font-size:42px;line-height:.95;font-weight:800;color:#fff;text-transform:uppercase;white-space:nowrap">{{ $blueName ?: 'BLUE' }}</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;{{ $hide($blueCountry) }}">
|
||||
<span style="font-size:14px;letter-spacing:.3em;color:#a8b6cf;text-transform:uppercase;{{ $hide($blueCountry) }}">{{ $blueCountry }}</span>
|
||||
<span data-msb="vsBlueFlag" style="width:40px;height:27px;flex:none;background:repeating-linear-gradient(135deg,rgba(255,255,255,.28) 0 5px,rgba(255,255,255,.1) 5px 10px);box-shadow:0 0 0 1px rgba(255,255,255,.32);display:flex;align-items:center;justify-content:center;font-family:ui-monospace,monospace;font-size:8px;color:rgba(255,255,255,.85)">FLAG</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-top:2px;{{ $hide($blueClub) }}">
|
||||
<span style="font-size:13px;letter-spacing:.18em;color:#9d9691;text-transform:uppercase">{{ $blueClub }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- bout line --}}
|
||||
<div style="position:absolute;bottom:26px;left:0;right:0;display:flex;align-items:center;justify-content:center;gap:16px;font-size:11px;letter-spacing:.32em;color:#8f8a83;text-transform:uppercase">
|
||||
<span style="{{ $hide($bout) }}">{{ $bout }}</span>
|
||||
<span style="width:4px;height:4px;background:#e8534a;{{ $hide($bout).$hide($tatami) }}"></span>
|
||||
<span style="{{ $hide($tatami) }}">{{ $tatami }}</span>
|
||||
<span style="width:4px;height:4px;background:#6aa6ff;{{ $hide($tatami).$hide($dateVenue) }}"></span>
|
||||
<span style="{{ $hide($dateVenue) }}">{{ $dateVenue }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,7 +150,8 @@
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
.replay-badge {
|
||||
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;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: clamp(4px, 0.7cqi, 8px) clamp(7px, 1.1cqi, 12px);
|
||||
@@ -2229,6 +2230,8 @@
|
||||
<span class="replay-badge-word">REPLAY</span>
|
||||
<span class="replay-badge-speed" id="replayBadgeSpeed">×1</span>
|
||||
</div>
|
||||
{{-- Karate/Taekwondo scoreboard overlay + VS intro + gear-menu toggles --}}
|
||||
@include('videos.partials.match.scoreboard.index')
|
||||
</x-slot:overlay>
|
||||
</x-video-player>
|
||||
|
||||
|
||||
Reference in new issue
Block a user