PageSourceSearch

https://xoilacc1.live/wp-content/plugins/Plugin_Live_Bong/assets/js/s8-live-football.js?ver=0.3.79

js xoilacc1.live collected 2026-09-29 09:10:01 UTC 63,586 bytes, 1,993 lines download raw bytes

1(function () {
2  const config = window.S8LiveFootball || {};
3  const strings = config.strings || {};
4  const roots = Array.from(document.querySelectorAll('.s8lf-root'));
5  const DEFAULT_PLACEHOLDER_HLS_URL =
6    'https://media.monitorhub.dev/media/hls/9b3eb51eaf154cf1bd333f60ea69dafc/media-2/stream.m3u8';
7  const TRACKER_BASE_URL = (config.trackerBaseUrl || '').replace(/\/$/, '');
8  const FILTER_TABS = [
9    { key: 'all', label: 'Tất cả', icon: 'all' },
10    { key: 'truc_tiep', label: 'Trực tiếp', icon: 'live' },
11    { key: 'tran_hot', label: 'Trận hot', icon: 'hot' },
12    { key: 'hom_nay', label: 'Hôm nay', icon: 'today' },
13    { key: 'ngay_mai', label: 'Ngày mai', icon: 'tomorrow' },
14  ];
15  const STAT_LABELS = {
16    1: 'Bàn thắng',
17    2: 'Phạt góc',
18    3: 'Thẻ vàng',
19    4: 'Thẻ đỏ',
20    5: 'Việt vị',
21    6: 'Đá phạt',
22    7: 'Phát bóng',
23    8: 'Penalty',
24    9: 'Thay người',
25    15: 'Nâng cấp thẻ',
26    16: 'Penalty hỏng',
27    17: 'Phản lưới',
28    21: 'Sút trúng đích',
29    22: 'Sút ra ngoài',
30    23: 'Tấn công',
31    24: 'Tấn công nguy hiểm',
32    25: 'Kiểm soát bóng',
33    32: 'Ném biên',
34    33: 'Rê bóng',
35    34: 'Rê bóng thành công',
36    36: 'Phá bóng',
37    37: 'Sút bị cản',
38    38: 'Cắt bóng',
39    39: 'Tắc bóng',
40    40: 'Chuyền bóng',
41    41: 'Chuyền chính xác',
42    42: 'Đường chuyền quyết định',
43    43: 'Tạt bóng',
44    44: 'Tạt bóng chính xác',
45    45: 'Chuyền dài',
46    46: 'Chuyền dài chính xác',
47    48: '1vs1 thành công',
48    49: 'Chuyền bị cắt',
49    52: 'Cứu thua',
50    53: 'Đấm bóng',
51    54: 'Phát bóng thủ môn',
52    55: 'Phát bóng chính xác',
53    56: 'Tấn công bóng bổng',
54    61: '1vs1 thất bại',
55    63: 'Đá phạt',
56    65: 'Bàn thắng đá phạt',
57    69: 'Trúng khung gỗ',
58    70: 'Phản công',
59    71: 'Dứt điểm phản công',
60    72: 'Bàn thắng phản công',
61    78: 'Mất bóng',
62    83: 'Tổng cú sút',
63  };
64  let fixturesPromise = null;
65  let homeLivePromise = null;
66
67  if (!roots.length) {
68    return;
69  }
70
71  function text(value, fallback) {
72    const raw = value === undefined || value === null ? fallback || '' : value;
73    return String(raw);
74  }
75
76  function normalizeStreamUrl(value) {
77    const raw = text(value).trim();
78    return raw ? raw : '';
79  }
80
81  function normalizePlayableStreamUrl(value) {
82    const raw = normalizeStreamUrl(value);
83    if (!raw || /^https?:\/\/1\/?$/i.test(raw)) {
84      return '';
85    }
86
87    return raw;
88  }
89
90  function normalizeHttpBaseUrl(value, fallback) {
91    let raw = normalizeStreamUrl(value);
92    if (!raw) {
93      raw = fallback;
94    } else if (raw.startsWith('//')) {
95      raw = `https:${raw}`;
96    } else if (!/^https?:\/\//i.test(raw)) {
97      raw = `https://${raw}`;
98    }
99
100    try {
101      const url = new URL(raw);
102      return url.toString().replace(/\/$/, '');
103    } catch {
104      return fallback.replace(/\/$/, '');
105    }
106  }
107
108  function escapeHtml(value) {
109    return text(value)
110      .replace(/&/g, '&')
111      .replace(/</g, '&lt;')
112      .replace(/>/g, '&gt;')
113      .replace(/"/g, '&quot;')
114      .replace(/'/g, '&#039;');
115  }
116
117  function isAppleDevice() {
118    const userAgent = navigator.userAgent || '';
119    const platform = navigator.platform || '';
120    const maxTouchPoints = navigator.maxTouchPoints || 0;
121
122    return (
123      /iPad|iPhone|iPod|Macintosh/i.test(userAgent) ||
124      (/Mac/i.test(platform) && maxTouchPoints > 1)
125    );
126  }
127
128  function canUseNativeHls(video) {
129    return Boolean(
130      video?.canPlayType?.('application/vnd.apple.mpegurl') ||
131        video?.canPlayType?.('application/x-mpegURL'),
132    );
133  }
134
135  function canUseFlv(streams) {
136    if (isAppleDevice()) {
137      return false;
138    }
139
140    return Boolean(
141      streams.mainFlv?.url &&
142        window.mpegts?.isSupported?.() &&
143        window.mpegts?.getFeatureList?.()?.mseLivePlayback,
144    );
145  }
146
147  function createStreamMap(stream) {
148    const flvUrl = normalizePlayableStreamUrl(stream?.link_flv);
149    const hlsUrl = normalizePlayableStreamUrl(stream?.link_m3u8);
150    const configuredPlaceholderUrl =
151      normalizePlayableStreamUrl(stream?.link_video_placeholder) ||
152      normalizePlayableStreamUrl(config.placeholderHlsUrl);
153    const placeholderUrl = configuredPlaceholderUrl || DEFAULT_PLACEHOLDER_HLS_URL;
154    const fallbackPlaceholderUrl =
155      configuredPlaceholderUrl && configuredPlaceholderUrl !== DEFAULT_PLACEHOLDER_HLS_URL
156        ? DEFAULT_PLACEHOLDER_HLS_URL
157        : '';
158
159    return {
160      mainFlv: flvUrl
161        ? {
162            type: 'flv',
163            url: flvUrl,
164          }
165        : null,
166      mainHls: hlsUrl
167        ? {
168            type: 'm3u8',
169            url: hlsUrl,
170          }
171        : null,
172      placeholderHls: {
173        type: 'm3u8',
174        url: placeholderUrl,
175      },
176      fallbackPlaceholderHls: fallbackPlaceholderUrl
177        ? {
178            type: 'm3u8',
179            url: fallbackPlaceholderUrl,
180          }
181        : null,
182    };
183  }
184
185  function isPlaceholderVariant(variant) {
186    return variant === 'placeholderHls' || variant === 'fallbackPlaceholderHls';
187  }
188
189  function resolveInitialVariant(streams) {
190    if (streams.mainHls?.url) {
191      return 'mainHls';
192    }
193
194    if (canUseFlv(streams)) {
195      return 'mainFlv';
196    }
197
198    return 'placeholderHls';
199  }
200
201  function safePlay(video, muted = true) {
202    if (!video) {
203      return Promise.resolve(false);
204    }
205
206    video.volume = 1;
207    video.muted = muted;
208
209    try {
210      const promise = video.play();
211      if (promise?.catch) {
212        return promise.then(() =>
212 true).catch(() => false);
213      }
214      return Promise.resolve(true);
215    } catch {}
216
217    return Promise.resolve(false);
218  }
219
220  function cleanupArtPlayer(art) {
221    if (!art) {
222      return;
223    }
224
225    if (typeof art.__s8lfCleanup === 'function') {
226      try {
227        art.__s8lfCleanup();
228      } catch {}
229      art.__s8lfCleanup = null;
230    }
231
232    try {
233      art.destroy();
234    } catch {}
235  }
236
237  async function fetchJson(url, options) {
238    const requestOptions = options || {};
239    const response = await fetch(url, {
240      ...requestOptions,
241      credentials: 'same-origin',
242      headers: {
243        Accept: 'application/json',
244        ...(requestOptions.headers ? requestOptions.headers : {}),
245      },
246    });
247    const payload = await response.json().catch(() => ({}));
248
249    if (!response.ok) {
250      throw new Error(payload.message || payload.error || 'Request failed');
251    }
252
253    return payload;
254  }
255
256  function getFixtures() {
257    if (!fixturesPromise) {
258      fixturesPromise = fetchJson(config.fixturesUrl).then((payload) =>
259        Array.isArray(payload.data) ? payload.data : [],
260      );
261    }
262
263    return fixturesPromise;
264  }
265
266  function getHomeLive() {
267    if (!config.homeLiveUrl) {
268      return Promise.resolve(null);
269    }
270
271    if (!homeLivePromise) {
272      homeLivePromise = fetchJson(config.homeLiveUrl).then((payload) => payload.data || null);
273    }
274
275    return homeLivePromise;
276  }
277
278  function getFixture(fixtureId) {
279    const endpoint = `${String(config.fixtureUrl || '').replace(/\/$/, '')}/${encodeURIComponent(fixtureId)}`;
280    return fetchJson(endpoint).then((payload) => payload.data || null);
281  }
282
283  function getStreams(fixture) {
284    if (Array.isArray(fixture?.streams)) {
285      return fixture.streams;
286    }
287    if (Array.isArray(fixture?.OBS_stream_key)) {
288      return fixture.OBS_stream_key;
289    }
290
291    return [];
292  }
293
294  function getActiveStreams(fixture) {
295    return getStreams(fixture).filter((stream) => stream && stream.is_active !== false);
296  }
297
298  function getDefaultStream(fixture) {
299    return getActiveStreams(fixture)[0] || null;
300  }
301
302  function boolAttr(value, fallback) {
303    if (value === undefined || value === null || value === '') {
304      return fallback;
305    }
306
307    return !['0', 'false', 'no', 'off'].includes(String(value).toLowerCase());
308  }
309
310  function themeMode() {
311    return config.themeMode === 'light' ? 'light' : 'dark';
312  }
313
314  function themeClass() {
315    return `s8lf-theme--${themeMode()}`;
316  }
317
318  function selectPrimaryFixture(fixtures, streamKey) {
319    if (streamKey) {
320      const streamFixture = fixtures.find((fixture) =>
321        getActiveStreams(fixture).some((stream) => stream.stream_key === streamKey),
322      );
323
324      if (streamFixture) {
325        return streamFixture;
326      }
327    }
328
329    return (
330      fixtures.find((fixture) => fixture?.time_type === 'truc_tiep' && getDefaultStream(fixture)) ||
331      fixtures.find((fixture) => getDefaultStream(fixture)) ||
332      fixtures[0] ||
333      null
334    );
335  }
336
337  function queryParam(name) {
338    try {
339      return new URLSearchParams(window.location.search).get(name) || '';
340    } catch {
341      return '';
342    }
343  }
344
345  function setAutoSoundIntent() {
346    try {
347      window.sessionStorage.setItem('s8lf_auto_sound', '1');
348      window.sessionStorage.setItem('s8lf_auto_sound_at', String(Date.now()));
349    } catch {}
350  }
351
352  function consumeAutoSoundIntent() {
353    const fromQuery = queryParam('s8_auto_sound') === '1';
354    let fromSession = false;
355
356    try {
357      const value = window.sessionStorage.getItem('s8lf_auto_sound');
358      const timestamp = Number(window.sessionStorage.getItem('s8lf_auto_sound_at') || 0);
359      fromSession = value === '1' && (!timestamp || Date.now() - timestamp < 120000);
360      window.sessionStorage.removeItem('s8lf_auto_sound');
361      window.sessionStorage.removeItem('s8lf_auto_sound_at');
362    } catch {}
363
364    return fromQuery || fromSession;
365  }
366
367  function bindAutoSoundLinks(root) {
368    root.querySelectorAll('[data-s8lf-auto-sound]').forEach((link) => {
369      link.addEventListener('click', setAutoSoundIntent);
370    });
371  }
372
373  function detailUrl(fixture, stream, options = {}) {
374    if (!fixture?.id) {
375      return '#';
376    }
377
378    const fixtureId = String(fixture.id);
379    const streamKey = stream?.stream_key
380      ? String(stream.stream_key)
381      : options.fallbackStreamKey
382        ? `fixture-${fixtureId}`
383        : '';
384
385    if (!streamKey) {
386      return '#';
387    }
388
389    const prettyUrl = `${String(config.detailBase || '/s8-live/').replace(/\/$/, '')}/${encodeURIComponent(fixtureId)}/${encodeURIComponent(streamKey)}/`;
390    const params = new URLSearchParams({
391      s8_live_fixture_id: fixtureId,
392      s8_live_stream_key: streamKey,
393    });
394
395    if (options.autoSound) {
396      params.set('s8_auto_sound', '1');
397    }
398
399    return `${prettyUrl}?${params.toString()}`;
400  }
401
402  function formatTime(iso) {
403    if (!iso) {
404      return '';
405    }
406
407    const date = new Date(iso);
408    if (Number.isNaN(date.getTime())) {
409      return '';
410    }
411
412    return date.toLocaleString('vi-VN', {
413      hour: '2-digit',
414      minute: '2-digit',
415      day: '2-digit',
416      month: '2-digit',
417    });
418  }
419
420  function formatKickoffTime(iso) {
421    if (!iso) {
422      return '--:--';
423    }
424
425    const date = new Date(iso);
426    if (Number.isNaN(date.getTime())) {
427      return '--:--';
428    }
429
430    return date.toLocaleTimeString('vi-VN', {
431      hour: '2-digit',
432      minute: '2-digit',
433    });
434  }
435
436  function formatKickoffDate(iso) {
437    if (!iso) {
438      return '';
439    }
440
441    const date = new Date(iso);
442    if (Number.isNaN(date.getTime())) {
443      return '';
444    }
445
446    return date.toLocaleDateString('vi-VN', {
447      day: '2-digit',
448      month: '2-digit',
449    });
450  }
451
452  function formatCountdown(iso) {
453    if (!iso) {
454      return '--';
455    }
456
457    const target = new Date(iso);
458    if (Number.isNaN(target.getTime())) {
459      return '--';
460    }
461
462    const diffMs = target.getTime() - Date.now();
463    const totalMinutes = Math.max(0, Math.ceil(diffMs / 60000));
464    const days = Math.floor(totalMinutes / 1440);
465    const hours = Math.floor((totalMinutes % 1440) / 60);
466    const minutes = totalMinutes % 60;
467
468    if (days > 0) {
469      return `${days}d ${hours}h`;
470    }
471    if (hours > 0) {
472      return `${hours}h ${minutes}p`;
473    }
474
475    return `${minutes}p`;
476  }
477
478  function statusLabel(fixture) {
479    if (fixture?.time_type === 'truc_tiep') {
480      return 'Đang diễn ra';
481    }
482
483    return 'Sắp diễn ra';
484  }
485
486  function isHotFixture(fixture) {
487    const value = fixture?.is_hot_match;
488    return value === true || value === 1 || value === '1' || value === 'true' || fixture?.time_type === 'tran_hot';
489  }
490
491  function matchesFilter(fixture, filterKey) {
492    if (filterKey === 'all') {
493      return true;
494    }
495
496    if (filterKey === 'tran_hot') {
497      return isHotFixture(fixture);
498    }
499
500    return fixture?.time_type === filterKey;
501  }
502
503  function buildFilterCounts(fixtures) {
504    return FILTER_TABS.reduce((counts, tab) => {
505      counts[tab.key] =
506        tab.key === 'all'
507          ? fixtures.length
508          : fixtures.filter((fixture) => matchesFilter(fixture, tab.key)).length;
509      return counts;
510    }, {});
511  }
512
513  function filterIcon(icon) {
514    const paths = {
515      all: '<circle cx="12" cy="12" r="7"></circle><circle cx="9" cy="12" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="15" cy="12" r="1"></circle>',
516      live: '<path d="M6.2 16.8a6.8 6.8 0 0 1 0-9.6"></path><path d="M17.8 7.2a6.8 6.8 0 0 1 0 9.6"></path><path d="M9 14a3 3 0 0 1 0-4"></path><path d="M15 10a3 3 0 0 1 0 4"></path><circle cx="12" cy="12" r="1.6"></circle>',
517      hot: '<path d="M12 21c3.4 0 6-2.4 6-5.7 0-2.2-1.2-4.1-3-5.3.2 1.9-.7 3-1.9 3.7.2-3.4-1.3-6.1-4.1-7.7.3 3.2-3 4.6-3 8.9C6 18.4 8.6 21 12 21Z"></path>',
518      today: '<path d="M7 3v3"></path><path d="M17 3v3"></path><path d="M4 9h16"></path><rect x="4" y="5" width="16" height="15" rx="3"></rect><path d="M8 13h3"></path><path d="M8 16h6"></path>',
519      tomorrow: '<path d="M7 3v3"></path><path d="M17 3v3"></path><path d="M4 9h16"></path><rect x="4" y="5" width="16" height="15" rx="3"></rect><circle cx="15" cy="15" r="3"></circle><path d="M15 13.4V15l1.2.8"></path>',
520    };
521
522    return `<svg class="s8lf-filter__svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths[icon] || paths.all}</svg>`;
523  }
524
525  function renderFilterBar(fixtures, activeFilter) {
526    const counts = buildFilterCounts(fixtures);
527
528    return `
529      <div class="s8lf-filterbar" role="tablist" aria-label="Lọc lịch thi đấu">
530        ${FILTER_TABS.map((tab) => {
531          const isActive = tab.key === activeFilter;
532          return `
533            <button
534              class="s8lf-filter${isActive ? ' is-active' : ''}"
535              type="button"
536              role="tab"
537              aria-selected="${isActive ? 'true' : 'false'}"
538              data-filter-key="${escapeHtml(tab.key)}"
539            >
540              <span class="s8lf-filter__icon">${filterIcon(tab.icon)}</span>
541              <span class="s8lf-filter__label">${escapeHtml(tab.label)}</span>
542              <span class="s8lf-filter__count">${escapeHtml(counts[tab.key] || 0)}</span>
543            </button>
544          `;
545        }).join('')}
546      </div>
547    `;
548  }
549
550  function renderError(root, error) {
551    root.innerHTML = `
552      <div class="s8lf-state s8lf-state--error">
553        <strong>Không tải được dữ liệu.</strong>
554        <span>${escapeHtml(error?.message || error || '')}</span>
555      </div>
556    `;
557  }
558
559  function renderLoading(root) {
560    root.innerHTML = `<div class="s8lf-state">${escapeHtml(strings.loading || 'Đang tải dữ liệu...')}</div>`;
561  }
562
563  function renderTeamLogo(url, name) {
564    if (url) {
565      return `<img class="s8lf-team-logo" src="${escapeHtml(url)}" alt="${escapeHtml(name)}" loading="lazy" />`;
566    }
567
568    const initial = text(name, '?').trim().charAt(0).toUpperCase() || '?';
569    return `<span class="s8lf-team-logo s8lf-team-logo--empty">${escapeHtml(initial)}</span>`;
570  }
571
572  function uniqueCommentators(fixture) {
573    const seen = new Set();
574    const commentators = [];
575
576    getActiveStreams(fixture).forEach((stream) => {
577      const commentator = stream?.commentator || {};
578      const name = text(commentator.name || 'BLV đang cập nhật').trim();
579      const avatarUrl = text(commentator.avatar_url || '').trim();
580      const key = `${name}-${avatarUrl}`;
581
582      if (seen.has(key)) {
583        return;
584      }
585
586      seen.add(key);
587      commentators.push({
588        name,
589        avatarUrl,
590      });
591    });
592
593    return commentators;
594  }
595
596  function renderCommentator(commentator, isLive) {
597    const name = commentator?.name || 'BLV đang cập nhật';
598    const initial = name.trim().charAt(0).toUpperCase() || 'B';
599
600    return `
601      <div class="s8lf-commentator">
602        <span class="s8lf-commentator__avatar${isLive ? ' is-live' : ''}">
603          ${
604            commentator?.avatarUrl
605              ? `<img src="${escapeHtml(commentator.avatarUrl)}" alt="${escapeHtml(name)}" loading="lazy" />`
606              : `<span>${escapeHtml(initial)}</span>`
607          }
608        </span>
609        <span>${escapeHtml(name)}</span>
610      </div>
611    `;
612  }
613
614  function renderHomeCommentatorSwitcher(fixture, currentStream, isLive) {
615    const streams = getActiveStreams(fixture);
616
617    if (!streams.length) {
618      return '<span class="s8lf-card__muted">Chưa có BLV</span>';
619    }
620
621    const activeStream = currentStream || streams[0];
622    const hasMenu = streams.length > 1;
623
624    return `
625      <div class="s8lf-switcher s8lf-switcher--commentators s8lf-switcher--card" data-s8lf-switcher>
626        <button
627          class="s8lf-switcher__button"
628          type="button"
629          data-s8lf-switcher-toggle
630          aria-expanded="false"
631          aria-haspopup="${hasMenu ? 'menu' : 'false'}"
632          aria-disabled="${hasMenu ? 'false' : 'true'}"
633        >
634          ${renderSwitcherAvatar(activeStream)}
635          <span class="s8lf-switcher__text">
636            <span>BLV</span>
637            <strong>${escapeHtml(currentCommentatorName(activeStream))}</strong>
638          </span>
639          ${hasMenu ? renderChevronIcon() : ''}
640        </button>
641
642        ${
643          hasMenu
644            ? `
645              <div class="s8lf-switcher__menu s8lf-switcher__menu--commentators" data-s8lf-switcher-menu role="menu">
646                <div class="s8lf-switcher__menu-title">Danh sách bình luận viên</div>
647                <div class="s8lf-switcher__menu-list">
648                  ${streams
649                    .map((stream) => {
650                      const isActive = stream.stream_key === activeStream?.stream_key;
651                      return `
652                        <a
653                          class="s8lf-switcher__option${isActive ? ' is-active' : ''}"
654                          href="${escapeHtml(detailUrl(fixture, stream, { autoSound: true }))}"
655                          role="menuitem"
656                          data-s8lf-auto-sound="1"
657                        >
658                          ${renderSwitcherAvatar(stream)}
659                          <span>${escapeHtml(currentCommentatorName(stream))}</span>
660                        </a>
661                      `;
662                    })
663                    .join('')}
664                </div>
665              </div>
666            `
667            : ''
668        }
669      </div>
670    `;
671  }
672
673  function renderFixtureCard(fixture) {
674    const stream = getDefaultStream(fixture);
675    const href = detailUrl(fixture, stream, { autoSound: true });
676    const disabled = !stream;
677    const leftName = fixture?.left_club?.name || 'Đội chủ nhà';
678    const rightName = fixture?.right_club?.name || 'Đội khách';
679    const competition = fixture?.competition?.name || 'Giải đấu';
680    const competitionAvatar = fixture?.competition?.avatar_url || '';
681    const isLive = fixture?.time_type === 'truc_tiep';
682    const liveClass = fixture?.time_type === 'truc_tiep' ? ' s8lf-card--live' : '';
683    const hotClass = fixture?.is_hot_match ? ' s8lf-card--hot' : '';
684    const kickoffTime = formatKickoffTime(fixture?.time);
685    const kickoffDate = formatKickoffDate(fixture?.time);
686    const countdownLabel = formatCountdown(fixture?.time);
687
688    return `
689      <div class="s8lf-card${liveClass}${hotClass}${disabled ? ' s8lf-card--disabled' : ''}" ${disabled ? 'aria-disabled="true"' : `role="link" tabindex="0" data-s8lf-card-link="${escapeHtml(href)}" data-s8lf-auto-sound="1"`}>
690        <div class="s8lf-card__inner">
691          <div class="s8lf-card__top">
692            <div class="s8lf-card__pill s8lf-card__competition-pill" title="${escapeHtml(competition)}">
693              ${
694                competitionAvatar
695                  ? `<img src="${escapeHtml(competitionAvatar)}" alt="${escapeHtml(competition)}" loading="lazy" />`
696                  : ''
697              }
698              <span>${escapeHtml(competition)}</span>
699            </div>
700            <div class="s8lf-card__time-stack">
701              <span class="s8lf-card__pill s8lf-card__time-pill">${escapeHtml(kickoffTime)}</span>
702              ${kickoffDate ? `<span class="s8lf-card__date">${escapeHtml(kickoffDate)}</span>` : ''}
703            </div>
704          </div>
705          <div class="s8lf-card__match">
706            <div class="s8lf-card__team">
707              ${renderTeamLogo(fixture?.left_club?.avatar_url, leftName)}
708              <strong>${escapeHtml(leftName)}</strong>
709            </div>
710            <div class="s8lf-card__center">
711              <span class="s8lf-card__status${isLive ? ' is-live' : ''}">${escapeHtml(statusLabel(fixture))}</span>
712              ${
713                isLive
714                  ? `
715                    <div class="s8lf-card__score-pill">
716                      <span>${escapeHtml(fixture?.left_score ?? '0')}</span>
717                      <span class="s8lf-card__score-sep">:</span>
718                      <span>${escapeHtml(fixture?.right_score ?? '0')}</span>
719                    </div>
720                  `
721                  : `
722                    <div class="s8lf-card__countdown">
723                      <span>Bắt đầu sau</span>
724                      <strong>${escapeHtml(countdownLabel)}</strong>
725                    </div>
726                  `
727              }
728            </div>
729            <div class="s8lf-card__team s8lf-card__team--right">
730              ${renderTeamLogo(fixture?.right_club?.avatar_url, rightName)}
731              <strong>${escapeHtml(rightName)}</strong>
732            </div>
733          </div>
734          <div class="s8lf-card__divider"></div>
735          <div class="s8lf-card__footer">
736            <div class="s8lf-card__commentators">
737              ${renderHomeCommentatorSwitcher(fixture, stream, isLive)}
738            </div>
739          </div>
740        </div>
741      </div>
742    `;
743  }
744
745  function bindDisabledCards(root) {
746    root.querySelectorAll('.s8lf-card--disabled').forEach((link) => {
747      link.addEventListener('click', (event) => event.preventDefault());
748    });
749  }
750
751  function bindHomeCardLinks(root) {
752    root.querySelectorAll('[data-s8lf-card-link]').forEach((card) => {
753      const openCard = (event) => {
754        if (event.target.closest('a, button, [data-s8lf-switcher]')) {
755          return;
756        }
757
758        const href = card.getAttribute('data-s8lf-card-link');
759        if (!href || href === '#') {
760          return;
761        }
762
763        setAutoSoundIntent();
764        window.location.href = href;
765      };
766
767      card.addEventListener('click', openCard);
768      card.addEventListener('keydown', (event) => {
769        if (event.key !== 'Enter' && event.key !== ' ') {
770          return;
771        }
772
773        event.preventDefault();
774        openCard(event);
775      });
776    });
777  }
778
779  function renderFilteredGrid(root, fixtures, filterKey) {
780    const grid = root.querySelector('[data-s8lf-grid]');
781    if (!grid) {
782      return;
783    }
784
785    grid.classList.remove('is-filtered-in');
786    grid.classList.add('is-filtering');
787
788    const items = fixtures.filter((fixture) => matchesFilter(fixture, filterKey));
789    grid.innerHTML = items.length
790      ? items.map(renderFixtureCard).join('')
791      : '<div class="s8lf-state s8lf-state--inline">Không có trận đấu phù hợp.</div>';
792
793    requestAnimationFrame(() => {
794      grid.classList.remove('is-filtering');
795      grid.classList.add('is-filtered-in');
796    });
797
798    bindDisabledCards(root);
799    bindHomeCardLinks(root);
800    bindAutoSoundLinks(root);
801    bindDetailControls(root);
802  }
803
804  async function renderList(root) {
805    renderLoading(root);
806
807    try {
808      const fixtures = await getFixtures();
809      let activeFilter = 'all';
810
811      root.innerHTML = `
812        <div class="s8lf-shell ${themeClass()}">
813          ${
814            fixtures.length
815              ? `
816                ${renderFilterBar(fixtures, activeFilter)}
817                <div class="s8lf-grid" data-s8lf-grid>${fixtures.map(renderFixtureCard).join('')}</div>
818              `
819              : `<div class="s8lf-state">${escapeHtml(strings.empty || 'Chưa có trận đấu.')}</div>`
820          }
821        </div>
822      `;
823
824      root.querySelectorAll('[data-filter-key]').forEach((button) => {
825        button.addEventListener('click', () => {
826          if (button.dataset.filterKey === activeFilter) {
827            return;
828          }
829
830          activeFilter = button.dataset.filterKey || 'all';
831
832          root.querySelectorAll('[data-filter-key]').forEach((item) => {
833            const isActive = item.dataset.filterKey === activeFilter;
834            item.classList.toggle('is-active', isActive);
835            item.classList.toggle('is-switching', false);
836            item.setAttribute('aria-selected', isActive ? 'true' : 'false');
837          });
838
839          button.classList.add('is-switching');
840          window.setTimeout(() => button.classList.remove('is-switching'), 260);
841          renderFilteredGrid(root, fixtures, activeFilter);
842        });
843      });
844
845      bindDisabledCards(root);
846      bindHomeCardLinks(root);
847      bindAutoSoundLinks(root);
848      bindDetailControls(root);
849    } catch (error) {
850      renderError(root, error);
851    }
852  }
853
854  function currentCommentatorName(stream) {
855    return text(stream?.commentator?.name || 'Bình luận viên').trim() || 'Bình luận viên';
856  }
857
858  function currentCommentatorAvatar(stream) {
859    return text(stream?.commentator?.avatar_url || '').trim();
860  }
861
862  function initialLetter(value, fallback) {
863    return text(value, fallback || 'B').trim().charAt(0).toUpperCase() || fallback || 'B';
864  }
865
866  function renderChevronIcon() {
867    return `
868      <svg class="s8lf-switcher__chevron" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
869        <path d="M5.25 7.5L10 12.25L14.75 7.5"></path>
870      </svg>
871    `;
872  }
873
874  function renderMonitorIcon() {
875    return `
876      <svg class="s8lf-switcher__monitor" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
877        <rect x="4" y="5" width="16" height="11" rx="2"></rect>
878        <path d="M9 20h6"></path>
879        <path d="M12 16v4"></path>
880        <path d="M10 10.5l4 2.5-4 2.5v-5Z"></path>
881      </svg>
882    `;
883  }
884
885  function renderSwitcherAvatar(stream) {
886    const name = currentCommentatorName(stream);
887    const avatar = currentCommentatorAvatar(stream);
888
889    return `
890      <span class="s8lf-switcher__avatar">
891        ${
892          avatar
893            ? `<img src="${escapeHtml(avatar)}" alt="${escapeHtml(name)}" loading="lazy" />`
894            : `<span>${escapeHtml(initialLetter(name, 'B'))}</span>`
895        }
896      </span>
897    `;
898  }
899
900  function switchFixtureItems(fixtures, currentFixture, currentStream) {
901    const seen = new Set();
902    const items = [];
903    const source = [currentFixture, ...(Array.isArray(fixtures) ? fixtures : [])].filter(Boolean);
904
905    source.sort((a, b) => {
906      const aIsCurrent = String(a?.id) === String(currentFixture?.id);
907      const bIsCurrent = String(b?.id) === String(currentFixture?.id);
908      if (aIsCurrent !== bIsCurrent) {
909        return aIsCurrent ? -1 : 1;
910      }
911
912      const aIsLive = a?.time_type === 'truc_tiep';
913      const bIsLive = b?.time_type === 'truc_tiep';
914      if (aIsLive !== bIsLive) {
915        return aIsLive ? -1 : 1;
916      }
917
918      const aTime = new Date(a?.time || 0).getTime() || 0;
919      const bTime = new Date(b?.time || 0).getTime() || 0;
920      return aTime - bTime;
921    });
922
923    source.forEach((fixture) => {
924      if (!fixture?.id || seen.has(String(fixture.id))) {
925        return;
926      }
927
928      const stream =
929        String(fixture.id) === String(currentFixture?.id)
930          ? currentStream || getDefaultStream(fixture)
931          : getDefaultStream(fixture);
932
933      seen.add(String(fixture.id));
934      items.push({ fixture, stream });
935    });
936
937    return items.slice(0, 12);
938  }
939
940  function renderSwitchFixtureItem(item, currentFixtureId) {
941    const fixture = item.fixture;
942    const stream = item.stream;
943    const isActive = String(fixture?.id) === String(currentFixtureId);
944    const isLive = fixture?.time_type === 'truc_tiep';
945    const competition = fixture?.competition?.name || 'Giải đấu';
946    const leftName = fixture?.left_club?.name || 'Đội chủ nhà';
947    const rightName = fixture?.right_club?.name || 'Đội khách';
948
949    return `
950      <a class="s8lf-switch-match${isActive ? ' is-active' : ''}" href="${escapeHtml(detailUrl(fixture, stream, { fallbackStreamKey: true }))}">
951        <span class="s8lf-switch-match__top">
952          <span>${escapeHtml(competition)}</span>
953          <strong>${escapeHtml(isLive ? 'Đang diễn ra' : 'Sắp diễn ra')}</strong>
954        </span>
955        <span class="s8lf-switch-match__body">
956          <span class="s8lf-switch-match__team">
957            ${renderTeamLogo(fixture?.left_club?.avatar_url, leftName)}
958            <span>${escapeHtml(leftName)}</span>
959          </span>
960          <span class="s8lf-switch-match__score">${escapeHtml(fixture?.left_score ?? '0')} - ${escapeHtml(fixture?.right_score ?? '0')}</span>
961          <span class="s8lf-switch-match__team s8lf-switch-match__team--right">
962            <span>${escapeHtml(rightName)}</span>
963            ${renderTeamLogo(fixture?.right_club?.avatar_url, rightName)}
964          </span>
965        </span>
966      </a>
967    `;
968  }
969
970  function renderDetailControls(fixture, currentStream, fixtures) {
971    const streams = getActiveStreams(fixture);
972    const commentatorName = currentCommentatorName(currentStream);
973    const hasCommentatorMenu = streams.length > 0;
974    const fixtureItems = switchFixtureItems(fixtures, fixture, currentStream);
975    const hasFixtureMenu = fixtureItems.length > 1;
976
977    return `
978      <div class="s8lf-detail-controls" data-s8lf-detail-controls>
979        <div class="s8lf-switcher s8lf-switcher--commentators" data-s8lf-switcher>
980          <button
981            class="s8lf-switcher__button"
982            type="button"
983            data-s8lf-switcher-toggle
984            aria-expanded="false"
985            aria-haspopup="${hasCommentatorMenu ? 'menu' : 'false'}"
986            aria-disabled="${hasCommentatorMenu ? 'false' : 'true'}"
987          >
988            ${renderSwitcherAvatar(currentStream)}
989            <span class="s8lf-switcher__text">
990              <span>BLV</span>
991              <strong>${escapeHtml(commentatorName)}</strong>
992            </span>
993            ${renderChevronIcon()}
994          </button>
995
996          ${
997            hasCommentatorMenu
998              ? `
999                <div class="s8lf-switcher__menu s8lf-switcher__menu--commentators" data-s8lf-switcher-menu role="menu">
1000                  <div class="s8lf-switcher__menu-title">Danh sách bình luận viên</div>
1001                  <div class="s8lf-switcher__menu-list">
1002                    ${streams
1003                      .map((stream) => {
1004                        const isActive = stream.stream_key === currentStream?.stream_key;
1005                        return `
1006                          <a
1007                            class="s8lf-switcher__option${isActive ? ' is-active' : ''}"
1008                            href="${escapeHtml(detailUrl(fixture, stream))}"
1009                            role="menuitem"
1010                          >
1011                            ${renderSwitcherAvatar(stream)}
1012                            <span>${escapeHtml(currentCommentatorName(stream))}</span>
1013                          </a>
1014                        `;
1015                      })
1016                      .join('')}
1017                  </div>
1018                </div>
1019              `
1020              : ''
1021          }
1022        </div>
1023
1024        <div class="s8lf-switcher s8lf-switcher--fixtures" data-s8lf-switcher>
1025          <button
1026            class="s8lf-switcher__button"
1027            type="button"
1028            data-s8lf-switcher-toggle
1029            aria-expanded="false"
1030            aria-haspopup="${hasFixtureMenu ? 'menu' : 'false'}"
1031            aria-disabled="${hasFixtureMenu ? 'false' : 'true'}"
1032          >
1033            <span class="s8lf-switcher__avatar s8lf-switcher__avatar--match">${renderMonitorIcon()}</span>
1034            <span class="s8lf-switcher__text">
1035              <span>Trận</span>
1036              <strong>Đổi trận</strong>
1037            </span>
1038            ${renderChevronIcon()}
1039          </button>
1040
1041          ${
1042            hasFixtureMenu
1043              ? `
1044                <div class="s8lf-switcher__menu s8lf-switcher__menu--fixtures" data-s8lf-switcher-menu role="menu">
1045                  <div class="s8lf-switcher__menu-title">Các trận đang diễn ra</div>
1046                  <div class="s8lf-switcher__menu-list">
1047                    ${fixtureItems
1048                      .map((item) => renderSwitchFixtureItem(item, fixture.id))
1049                      .join('')}
1050                  </div>
1051                </div>
1052              `
1053              : ''
1054          }
1055        </div>
1056        ${(function () {
1057          const btns = (config.promoBtns) || {};
1058          const b1 = btns.nhan88k || {};
1059          const b2 = btns.cuocNgay || {};
1060          if (!b1.img && !b2.img) return '';
1061          return `<div class="s8lf-promo-btns">
1062            ${b1.img ? `<a href="${escapeHtml(b1.href || '#')}" target="_blank" rel="nofollow sponsored noopener noreferrer" class="s8lf-promo-btn s8lf-promo-btn--nhan88k"><img src="${escapeHtml(b1.img)}" alt="Nhận 88K Free"></a>` : ''}
1063            ${b2.img ? `<a href="${escapeHtml(b2.href || '#')}" target="_blank" rel="nofollow sponsored noopener noreferrer" class="s8lf-promo-btn s8lf-promo-btn--cuoc-ngay"><img src="${escapeHtml(b2.img)}" alt="Cược Ngay"></a>` : ''}
1064          </div>`;
1065        })()}
1066      </div>
1067    `;
1068  }
1069
1070  function bindDetailControls(root) {
1071    const switchers = Array.from(root.querySelectorAll('[data-s8lf-switcher]'));
1072    if (!switchers.length) {
1073      return;
1074    }
1075
1076    if (typeof root.__s8lfCloseSwitchers === 'function') {
1077      root.__s8lfCloseSwitchers();
1078    }
1079
1080    function closeSwitchers(except) {
1081      switchers.forEach((switcher) => {
1082        if (except && switcher === except) {
1083          return;
1084        }
1085
1086        switcher.classList.remove('is-open');
1087        switcher.querySelector('[data-s8lf-switcher-toggle]')?.setAttribute('aria-expanded', 'false');
1088      });
1089    }
1090
1091    switchers.forEach((switcher) => {
1092      const button = switcher.querySelector('[data-s8lf-switcher-toggle]');
1093      const menu = switcher.querySelector('[data-s8lf-switcher-menu]');
1094
1095      if (!button || !menu) {
1096        return;
1097      }
1098
1099      button.addEventListener('click', (event) => {
1100        event.preventDefault();
1101        const isOpen = switcher.classList.contains('is-open');
1102        closeSwitchers(switcher);
1103        switcher.classList.toggle('is-open', !isOpen);
1104        button.setAttribute('aria-expanded', !isOpen ? 'true' : 'false');
1105      });
1106    });
1107
1108    const handleDocumentClick = (event) => {
1109      if (!root.contains(event.target)) {
1110        closeSwitchers();
1111      }
1112    };
1113
1114    const handleKeydown = (event) => {
1115      if (event.key === 'Escape') {
1116        closeSwitchers();
1117      }
1118    };
1119
1120    document.addEventListener('click', handleDocumentClick);
1121    document.addEventListener('keydown', handleKeydown);
1122
1123    root.__s8lfCloseSwitchers = () => {
1124      document.removeEventListener('click', handleDocumentClick);
1125      document.removeEventListener('keydown', handleKeydown);
1126    };
1127  }
1128
1129  function renderVideoShell(fixture, currentStream, fixtures) {
1130    const leftName = fixture?.left_club?.name || 'Đội chủ nhà';
1131    const rightName = fixture?.right_club?.name || 'Đội khách';
1132    const competition = fixture?.competition?.name || 'Giải đấu';
1133    const timeLabel = formatTime(fixture?.time);
1134
1135    return `
1136      <section class="s8lf-player-panel">
1137        <div class="s8lf-player-titlebar">
1138          <strong>${escapeHtml(competition)} - ${escapeHtml(leftName)} VS ${escapeHtml(rightName)}${timeLabel ? ` - ${escapeHtml(timeLabel)}` : ''}</strong>
1139        </div>
1140        <div class="s8lf-match-strip">
1141          <div class="s8lf-match-team s8lf-match-team--left">
1142            ${renderTeamLogo(fixture?.left_club?.avatar_url, leftName)}
1143            <span>${escapeHtml(leftName)}</span>
1144          </div>
1145          <div class="s8lf-match-score">${escapeHtml(fixture?.left_score ?? '0')} - ${escapeHtml(fixture?.right_score ?? '0')}</div>
1146          <div class="s8lf-match-team s8lf-match-team--right">
1147            <span>${escapeHtml(rightName)}</span>
1148            ${renderTeamLogo(fixture?.right_club?.avatar_url, rightName)}
1149          </div>
1150        </div>
1151        <div class="s8lf-video" data-player>
1152          <div class="s8lf-video__art" data-art-player></div>
1153          <div class="s8lf-video__empty" hidden></div>
1154          <button class="s8lf-sound-toggle" type="button" data-sound-toggle aria-label="Bật âm thanh">
1155            <span class="s8lf-sound-toggle__icon" aria-hidden="true">
1156              <svg viewBox="0 0 24 24" focusable="false">
1157                <path d="M4 10v4h4l5 4V6l-5 4H4Z"></path>
1158                <path d="M16 9.5c.8.7 1.2 1.5 1.2 2.5S16.8 13.8 16 14.5"></path>
1159                <path d="M18.6 7c1.5 1.3 2.4 3 2.4 5s-.9 3.7-2.4 5"></path>
1160              </svg>
1161            </span>
1162            <span>Bật âm thanh</span>
1163          </button>
1164        </div>
1165        ${renderDetailControls(fixture, currentStream, fixtures)}
1166      </section>
1167    `;
1168  }
1169
1170  function mountPlayer(root, stream, options = {}) {
1171    const player = root.querySelector('[data-player]');
1172    const artContainer = player?.querySelector('[data-art-player]');
1173    const empty = player?.querySelector('.s8lf-video__empty');
1174    const soundToggle = player?.querySelector('[data-sound-toggle]');
1175
1176    if (!player || !artContainer || !empty) {
1177      return;
1178    }
1179
1180    if (!window.Artplayer) {
1181      empty.hidden = false;
1182      empty.textContent = 'ArtPlayer chưa sẵn sàng.';
1183      return;
1184    }
1185
1186    const streams = createStreamMap(stream);
1187    let currentArt = null;
1188    let currentVariant = null;
1189    let loadFallbackTimer = null;
1190    let soundEnabled = !!options.autoSound;
1191    const failedVariants = new Set();
1192
1193    function syncSoundToggle() {
1194      if (!soundToggle) {
1195        return;
1196      }
1197
1198      soundToggle.hidden = soundEnabled;
1199      soundToggle.setAttribute('aria-pressed', soundEnabled ? 'true' : 'false');
1200      player.classList.toggle('s8lf-video--sound-on', soundEnabled);
1201    }
1202
1203    function applySoundState(video) {
1204      if (!video) {
1205        return;
1206      }
1207
1208      video.volume = 1;
1209      video.muted = !soundEnabled;
1210    }
1211
1212    function enableSound() {
1213      soundEnabled = true;
1214      applySoundState(currentArt?.video);
1215      syncSoundToggle();
1216
1217      safePlay(currentArt?.video, false).then((played) => {
1218        if (!played) {
1219          soundEnabled = false;
1220          applySoundState(currentArt?.video);
1221          syncSoundToggle();
1222        }
1223      });
1224
1225      if (currentArt) {
1226        try {
1227          currentArt.muted = false;
1228        } catch {}
1229        try {
1230          currentArt.volume = 1;
1231        } catch {}
1232      }
1233    }
1234
1235    function playWithCurrentSound(video) {
1236      const target = video || currentArt?.video;
1237      safePlay(target, !soundEnabled).then((played) => {
1238        if (soundEnabled && !played) {
1239          soundEnabled = false;
1240          applySoundState(target);
1241          syncSoundToggle();
1242        }
1243      });
1244    }
1245
1246    syncSoundToggle();
1247
1248    if (soundToggle) {
1249      soundToggle.addEventListener('click', enableSound);
1250    }
1251
1252    function showEmpty(message) {
1253      if (loadFallbackTimer) {
1254        window.clearTimeout(loadFallbackTimer);
1255        loadFallbackTimer = null;
1256      }
1257      empty.hidden = false;
1258      empty.textContent = message;
1259      artContainer.innerHTML = '';
1260      player.classList.add('s8lf-video--empty');
1261      syncSoundToggle();
1262    }
1263
1264    function setVariant(nextVariant) {
1265      const currentStream = streams[nextVariant];
1266      if (!currentStream?.url) {
1267        showEmpty('Stream chưa sẵn sàng.');
1268        return;
1269      }
1270
1271      if (currentVariant === nextVariant && currentArt) {
1272        return;
1273      }
1274
1275      if (loadFallbackTimer) {
1276        window.clearTimeout(loadFallbackTimer);
1277        loadFallbackTimer = null;
1278      }
1279
1280      currentVariant = nextVariant;
1281      empty.hidden = true;
1282      player.classList.remove('s8lf-video--empty');
1283      player.classList.toggle('s8lf-video--placeholder', isPlaceholderVariant(nextV
1283ariant));
1284      syncSoundToggle();
1285      cleanupArtPlayer(currentArt);
1286      artContainer.innerHTML = '';
1287
1288      function fallbackToHls() {
1289        if (currentVariant && !isPlaceholderVariant(currentVariant)) {
1290          failedVariants.add(currentVariant);
1291        }
1292
1293        if (
1294          streams.mainHls?.url &&
1295          currentVariant !== 'mainHls' &&
1296          !failedVariants.has('mainHls')
1297        ) {
1298          setVariant('mainHls');
1299          return;
1300        }
1301
1302        fallbackToPlaceholder();
1303      }
1304
1305      function fallbackToFlv() {
1306        if (
1307          canUseFlv(streams) &&
1308          streams.mainFlv?.url &&
1309          currentVariant !== 'mainFlv' &&
1310          !failedVariants.has('mainFlv')
1311        ) {
1312          setVariant('mainFlv');
1313          return;
1314        }
1315
1316        fallbackToPlaceholder();
1317      }
1318
1319      function fallbackFromHls() {
1320        if (!isPlaceholderVariant(currentVariant)) {
1321          failedVariants.add(currentVariant);
1322          fallbackToFlv();
1323          return;
1324        }
1325
1326        fallbackToPlaceholder();
1327      }
1328
1329      function fallbackToPlaceholder() {
1330        if (!isPlaceholderVariant(currentVariant)) {
1331          setVariant('placeholderHls');
1332          return;
1333        }
1334
1335        if (currentVariant === 'placeholderHls' && streams.fallbackPlaceholderHls?.url) {
1336          setVariant('fallbackPlaceholderHls');
1337          return;
1338        }
1339
1340        showEmpty('Không phát được video.');
1341      }
1342
1343      function markVideoReady(video) {
1344        if (loadFallbackTimer) {
1345          window.clearTimeout(loadFallbackTimer);
1346          loadFallbackTimer = null;
1347        }
1348
1349        playWithCurrentSound(video || currentArt?.video);
1350      }
1351
1352      function scheduleLoadFallback() {
1353        if (isPlaceholderVariant(nextVariant)) {
1354          return;
1355        }
1356
1357        loadFallbackTimer = window.setTimeout(() => {
1358          if (currentVariant === nextVariant) {
1359            if (nextVariant === 'mainHls') {
1360              fallbackFromHls();
1361              return;
1362            }
1363
1364            if (nextVariant === 'mainFlv') {
1365              fallbackToHls();
1366              return;
1367            }
1368
1369            fallbackToPlaceholder();
1370          }
1371        }, 8000);
1372      }
1373
1374      function mountFlv(video, url, art) {
1375        if (!canUseFlv(streams)) {
1376          fallbackToHls();
1377          return;
1378        }
1379
1380        const mpegtsPlayer = window.mpegts.createPlayer({
1381          type: 'flv',
1382          url,
1383          isLive: true,
1384        });
1385        const handleError = () => fallbackToHls();
1386        const handleVideoError = () => fallbackToHls();
1387        const handleCanPlay = () => markVideoReady(video);
1388
1389        mpegtsPlayer.on(window.mpegts.Events.ERROR, handleError);
1390        video.addEventListener('error', handleVideoError);
1391        video.addEventListener('loadeddata', handleCanPlay);
1392        video.addEventListener('canplay', handleCanPlay);
1393        video.addEventListener('playing', handleCanPlay);
1394        mpegtsPlayer.attachMediaElement(video);
1395        mpegtsPlayer.load();
1396        playWithCurrentSound(video);
1397
1398        art.__s8lfCleanup = () => {
1399          video.removeEventListener('error', handleVideoError);
1400          video.removeEventListener('loadeddata', handleCanPlay);
1401          video.removeEventListener('canplay', handleCanPlay);
1402          video.removeEventListener('playing', handleCanPlay);
1403          try {
1404            mpegtsPlayer.off(window.mpegts.Events.ERROR, handleError);
1405          } catch {}
1406          try {
1407            mpegtsPlayer.pause();
1408          } catch {}
1409          try {
1410            mpegtsPlayer.unload();
1411          } catch {}
1412          try {
1413            mpegtsPlayer.detachMediaElement();
1414          } catch {}
1415          try {
1416            mpegtsPlayer.destroy();
1417          } catch {}
1418        };
1419      }
1420
1421      function mountHls(video, url, art) {
1422        function attachNativeHls() {
1423          const handleLoaded = () => markVideoReady(video);
1424          const handleVideoError = () => fallbackFromHls();
1425
1426          video.dataset.s8lfEngine = 'native-hls';
1427          video.src = url;
1428          video.addEventListener('loadedmetadata', handleLoaded);
1429          video.addEventListener('canplay', handleLoaded);
1430          video.addEventListener('error', handleVideoError);
1431          playWithCurrentSound(video);
1432
1433          art.__s8lfCleanup = () => {
1434            video.removeEventListener('loadedmetadata', handleLoaded);
1435            video.removeEventListener('canplay', handleLoaded);
1436            video.removeEventListener('error', handleVideoError);
1437            try {
1438              video.removeAttribute('src');
1439              delete video.dataset.s8lfEngine;
1440              video.load();
1441            } catch {}
1442          };
1443        }
1444
1445        function attachHlsJs() {
1446          const hls = new window.Hls({
1447            enableWorker: true,
1448            lowLatencyMode: true,
1449            backBufferLength: 60,
1450          });
1451          const handleManifest = () => markVideoReady(video);
1452          const handleError = (_event, data) => {
1453            if (!data?.fatal) {
1454              return;
1455            }
1456
1457            if (
1458              data.type === window.Hls.ErrorTypes.NETWORK_ERROR &&
1459              !isPlaceholderVariant(currentVariant)
1460            ) {
1461              fallbackFromHls();
1462              return;
1463            }
1464
1465            if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
1466              try {
1467                hls.recoverMediaError();
1468              } catch {
1469                fallbackFromHls();
1470              }
1471              return;
1472            }
1473
1474            fallbackFromHls();
1475          };
1476          const handleVideoError = () => fallbackFromHls();
1477
1478          video.dataset.s8lfEngine = 'hls.js';
1479          hls.on(window.Hls.Events.MANIFEST_PARSED, handleManifest);
1480          hls.on(window.Hls.Events.ERROR, handleError);
1481          const handleVideoReady = () => markVideoReady(video);
1482          video.addEventListener('loadeddata', handleVideoReady);
1483          video.addEventListener('canplay', handleVideoReady);
1484          video.addEventListener('playing', handleVideoReady);
1485          video.addEventListener('error', handleVideoError);
1486          hls.loadSource(url);
1487          hls.attachMedia(video);
1488
1489          art.__s8lfCleanup = () => {
1490            video.removeEventListener('loadeddata', handleVideoReady);
1491            video.removeEventListener('canplay', handleVideoReady);
1492            video.removeEventListener('playing', handleVideoReady);
1493            video.removeEventListener('error', handleVideoError);
1494            try {
1495              hls.off(window.Hls.Events.MANIFEST_PARSED, handleManifest);
1496            } catch {}
1497            try {
1498              hls.off(window.Hls.Events.ERROR, handleError);
1499            } catch {}
1500            try {
1501              delete video.dataset.s8lfEngine;
1502              hls.destroy();
1503            } catch {}
1504          };
1505
1506        }
1507
1508        if (isAppleDevice() && canUseNativeHls(video)) {
1509          attachNativeHls();
1510          return;
1511        }
1512
1513        if (window.Hls?.isSupported?.()) {
1514          attachHlsJs();
1515          return;
1516        }
1517
1518        if (canUseNativeHls(video)) {
1519          attachNativeHls();
1520          return;
1521        }
1522
1523        fallbackFromHls();
1524      }
1525
1526      scheduleLoadFallback();
1527
1528      currentArt = new window.Artplayer({
1529        container: artContainer,
1530        url: currentStream.url,
1531        type: currentStream.type,
1532        isLive: !isPlaceholderVariant(nextVariant),
1533        autoplay: true,
1534        muted: true,
1535        playsInline: true,
1536        fullscreen: !isPlaceholderVariant(nextVariant),
1537        fullscreenWeb: !isPlaceholderVariant(nextVariant),
1538        customType: {
1539          flv: mountFlv,
1540          m3u8: mountHls,
1541        },
1542      });
1543
1544      if (currentArt.video) {
1545        currentArt.video.setAttribute('playsinline', '');
1546        currentArt.video.setAttribute('webkit-playsinline', '');
1547        applySoundState(currentArt.video);
1548      }
1549    }
1550
1551    if (!streams.mainFlv?.url && !streams.mainHls?.url && !streams.placeholderHls?.url) {
1552      showEmpty('Stream chưa sẵn sàng.');
1553      return;
1554    }
1555
1556    setVariant(resolveInitialVariant(streams));
1557  }
1558
1559  function chatIframeUrl(streamKey) {
1560    const baseUrl = normalizeHttpBaseUrl(config.chatAppBaseUrl, 'https://lauthaitv.site');
1561    const roomId = streamKey || 'general';
1562    const url = new URL(`${baseUrl.replace(/\/$/, '')}/${encodeURIComponent(roomId)}`);
1563    url.searchParams.set('embed', '1');
1564    url.searchParams.set('theme', themeMode());
1565
1566    return url.toString();
1567  }
1568
1569  function parseMaybeJson(value, fallback) {
1570    if (Array.isArray(value)) {
1571      return value;
1572    }
1573
1574    if (typeof value === 'string' && value.trim()) {
1575      try {
1576        const parsed = JSON.parse(value);
1577        return Array.isArray(parsed) ? parsed : fallback;
1578      } catch {
1579        return fallback;
1580      }
1581    }
1582
1583    return fallback;
1584  }
1585
1586  function scoreArray(value) {
1587    return parseMaybeJson(value, []).map((item) => {
1588      const number = Number(item);
1589      return Number.isFinite(number) ? number : 0;
1590    });
1591  }
1592
1593  function scoreAt(value, index, fallback = 0) {
1594    const scores = scoreArray(value);
1595    return scores[index] ?? fallback;
1596  }
1597
1598  function fixtureHomeScores(fixture) {
1599    return fixture?.home_scores || fixture?.reference_match_id?.home_scores || [];
1600  }
1601
1602  function fixtureAwayScores(fixture) {
1603    return fixture?.away_scores || fixture?.reference_match_id?.away_scores || [];
1604  }
1605
1606  function displayScore(fixture, side) {
1607    const scores = side === 'home' ? fixtureHomeScores(fixture) : fixtureAwayScores(fixture);
1608    const fallback = side === 'home' ? fixture?.left_score : fixture?.right_score;
1609    const value = scoreAt(scores, 0, Number(fallback || 0));
1610    return Number.isFinite(value) ? value : fallback || 0;
1611  }
1612
1613  function normalizeStats(value) {
1614    return parseMaybeJson(value, [])
1615      .map((item) => {
1616        const type = Number(item?.type);
1617        const left = Number(item?.home);
1618        const right = Number(item?.away);
1619
1620        return {
1621          type,
1622          label: STAT_LABELS[type] || (type ? `Chỉ số ${type}` : ''),
1623          left: Number.isFinite(left) ? left : 0,
1624          right: Number.isFinite(right) ? right : 0,
1625          isPercent: type === 25,
1626        };
1627      })
1628      .filter((item) => item.label)
1629      .sort((left, right) => left.type - right.type);
1630  }
1631
1632  function simulationUrl(fixture) {
1633    const matchId = text(fixture?.match_id || fixture?.reference_match_id?.matchI
1633d || '').trim();
1634    if (!matchId) {
1635      return '';
1636    }
1637
1638    if (!TRACKER_BASE_URL) {
1639      return '';
1640    }
1641
1642    let url;
1643    try {
1644      url = new URL(`${TRACKER_BASE_URL.replace(/\/$/, '')}/3d/football`);
1645    } catch (error) {
1646      return '';
1647    }
1648
1649    url.searchParams.set('profile', '1eryohg92i2udqw');
1650    url.searchParams.set('uuid', matchId);
1651    return url.toString();
1652  }
1653
1654  function renderChatPanel(streamKey) {
1655    const roomId = streamKey || 'general';
1656
1657    return `
1658      <iframe
1659        class="s8lf-chat__iframe"
1660        title="S8 chat ${escapeHtml(roomId)}"
1661        src="${escapeHtml(chatIframeUrl(roomId))}"
1662        loading="eager"
1663        scrolling="yes"
1664        referrerpolicy="no-referrer-when-downgrade"
1665        allow="clipboard-write"
1666      ></iframe>
1667    `;
1668  }
1669
1670  function renderSimulationPanel(fixture) {
1671    const url = simulationUrl(fixture);
1672
1673    if (!url) {
1674      return `
1675        <div class="s8lf-simulation__empty">
1676          Không có mô phỏng cho trận này.
1677        </div>
1678      `;
1679    }
1680
1681    return `
1682      <div class="s8lf-simulation__viewport">
1683        <iframe
1684          class="s8lf-simulation__iframe s8lf-simulation__iframe--desktop"
1685          title="Mô phỏng trận đấu"
1686          src="${escapeHtml(url)}"
1687          loading="eager"
1688          scrolling="no"
1689          referrerpolicy="no-referrer-when-downgrade"
1690          allowfullscreen
1691        ></iframe>
1692        <iframe
1693          class="s8lf-simulation__iframe s8lf-simulation__iframe--mobile"
1694          title="Mô phỏng trận đấu"
1695          src="${escapeHtml(url)}"
1696          loading="eager"
1697          scrolling="no"
1698          referrerpolicy="no-referrer-when-downgrade"
1699          allowfullscreen
1700        ></iframe>
1701      </div>
1702    `;
1703  }
1704
1705  function renderMatchStatsPanel(fixture) {
1706    const rawStats = fixture?.stats || fixture?.reference_match_id?.stats || [];
1707    const stats = normalizeStats(rawStats);
1708    const leftName = fixture?.left_club?.name || 'Đội nhà';
1709    const rightName = fixture?.right_club?.name || 'Đội khách';
1710    const leftScore = displayScore(fixture, 'home');
1711    const rightScore = displayScore(fixture, 'away');
1712
1713    return `
1714      <div class="s8lf-match-stats">
1715        <div class="s8lf-match-stats__title">Dữ liệu trận đấu</div>
1716        <div class="s8lf-match-stats__scoreline">
1717          <span>${escapeHtml(leftName)}</span>
1718          <strong>${escapeHtml(leftScore)} - ${escapeHtml(rightScore)}</strong>
1719          <span>${escapeHtml(rightName)}</span>
1720        </div>
1721        ${
1722          stats.length
1723            ? `<div class="s8lf-match-stats__rows">
1724                ${stats
1725                  .map((stat) => {
1726                    const maxValue = Math.max(stat.left, stat.right, 1);
1727                    const leftWidth = Math.min(50, Math.max(0, (stat.left / maxValue) * 50));
1728                    const rightWidth = Math.min(50, Math.max(0, (stat.right / maxValue) * 50));
1729                    const leftValue = stat.isPercent ? `${stat.left}%` : stat.left;
1730                    const rightValue = stat.isPercent ? `${stat.right}%` : stat.right;
1731
1732                    return `
1733                      <div class="s8lf-match-stats__row">
1734                        <div class="s8lf-match-stats__numbers">
1735                          <span>${escapeHtml(leftValue)}</span>
1736                          <b>${escapeHtml(stat.label)}</b>
1737                          <span>${escapeHtml(rightValue)}</span>
1738                        </div>
1739                        <div class="s8lf-match-stats__bar" aria-hidden="true">
1740                          <i class="is-left" style="width:${leftWidth}%;"></i>
1741                          <i class="is-right" style="width:${rightWidth}%;"></i>
1742                        </div>
1743                      </div>
1744                    `;
1745                  })
1746                  .join('')}
1747              </div>`
1748            : `<div class="s8lf-match-stats__empty">Không có dữ liệu trận đấu</div>`
1749        }
1750      </div>
1751    `;
1752  }
1753
1754  function renderSimulationStatsSection(fixture) {
1755    if (!simulationUrl(fixture) && !normalizeStats(fixture?.stats || fixture?.reference_match_id?.stats || []).length) {
1756      return '';
1757    }
1758
1759    return `
1760      <section class="s8lf-simulation-section ${themeClass()}">
1761        <div class="s8lf-simulation-section__grid">
1762          <div class="s8lf-simulation-section__media">
1763            ${renderSimulationPanel(fixture)}
1764          </div>
1765          ${renderMatchStatsPanel(fixture)}
1766        </div>
1767      </section>
1768    `;
1769  }
1770
1771  function renderLiveSidePanel(fixture, streamKey) {
1772    return `
1773      <aside class="s8lf-side-panel" data-s8lf-side-panel>
1774        <div class="s8lf-side-tabs" role="tablist" aria-label="Nội dung phòng live">
1775          <button
1776            class="s8lf-side-tab is-active"
1777            type="button"
1778            role="tab"
1779            aria-selected="true"
1780            data-s8lf-side-tab="chat"
1781          >Bình luận</button>
1782          <button
1783            class="s8lf-side-tab"
1784            type="button"
1785            role="tab"
1786            aria-selected="false"
1787            data-s8lf-side-tab="simulation"
1788          >Mô phỏng</button>
1789          <button
1790            class="s8lf-side-tab"
1791            type="button"
1792            role="tab"
1793            aria-selected="false"
1794            data-s8lf-side-tab="stats"
1795          >Thống số</button>
1796        </div>
1797        <div class="s8lf-side-pane is-active" data-s8lf-side-pane="chat">
1798          ${renderChatPanel(streamKey)}
1799        </div>
1800        <div class="s8lf-side-pane" data-s8lf-side-pane="simulation">
1801          ${renderSimulationPanel(fixture)}
1802        </div>
1803        <div class="s8lf-side-pane" data-s8lf-side-pane="stats">
1804          ${renderMatchStatsPanel(fixture)}
1805        </div>
1806      </aside>
1807    `;
1808  }
1809
1810  function bindSidePanelTabs(root) {
1811    root.querySelectorAll('[data-s8lf-side-panel]').forEach((panel) => {
1812      const tabs = Array.from(panel.querySelectorAll('[data-s8lf-side-tab]'));
1813      const panes = Array.from(panel.querySelectorAll('[data-s8lf-side-pane]'));
1814
1815      tabs.forEach((tab) => {
1816        tab.addEventListener('click', () => {
1817          const key = tab.getAttribute('data-s8lf-side-tab') || 'chat';
1818
1819          tabs.forEach((item) => {
1820            const active = item === tab;
1821            item.classList.toggle('is-active', active);
1822            item.setAttribute('aria-selected', active ? 'true' : 'false');
1823          });
1824
1825          panes.forEach((pane) => {
1826            pane.classList.toggle('is-active', pane.getAttribute('data-s8lf-side-pane') === key);
1827          });
1828        });
1829      });
1830    });
1831  }
1832
1833  function renderFixtureSwitch(fixtures, currentFixtureId) {
1834    const items = fixtures
1835      .filter((fixture) => String(fixture.id) !== String(currentFixtureId))
1836      .slice(0, 8);
1837
1838    if (!items.length) {
1839      return '';
1840    }
1841
1842    return `
1843      <section class="s8lf-shell s8lf-more ${themeClass()}">
1844        <div class="s8lf-grid s8lf-grid--compact">
1845          ${items.map(renderFixtureCard).join('')}
1846        </div>
1847      </section>
1848    `;
1849  }
1850
1851  async function renderDetail(root) {
1852    renderLoading(root);
1853
1854    const fixtureId = root.dataset.fixtureId || config.fixtureId || queryParam('s8_live_fixture_id');
1855    const streamKey = decodeURIComponent(
1856      root.dataset.streamKey || config.streamKey || queryParam('s8_live_stream_key') || '',
1857    );
1858    const autoSound = consumeAutoSoundIntent();
1859
1860    try {
1861      const [fixture, fixtures] = await Promise.all([
1862        getFixture(fixtureId),
1863        getFixtures().catch(() => []),
1864      ]);
1865
1866      if (!fixture) {
1867        throw new Error('Không tìm thấy trận đấu.');
1868      }
1869
1870      const streams = getActiveStreams(fixture);
1871      const currentStream =
1872        streams.find((stream) => stream.stream_key === streamKey) || streams[0] || null;
1873
1874      root.innerHTML = `
1875        <div class="s8lf-detail ${themeClass()}">
1876          <div class="s8lf-live-layout">
1877            ${renderVideoShell(fixture, currentStream, fixtures)}
1878            ${renderLiveSidePanel(fixture, currentStream?.stream_key || streamKey || `fixture-${fixture.id}`)}
1879          </div>
1880          ${renderSimulationStatsSection(fixture)}
1881          ${renderFixtureSwitch(fixtures, fixture.id)}
1882        </div>
1883      `;
1884
1885      mountPlayer(root, currentStream, { autoSound });
1886      bindDetailControls(root);
1887      bindSidePanelTabs(root);
1888      bindDisabledCards(root);
1889    } catch (error) {
1890      renderError(root, error);
1891    }
1892  }
1893
1894  async function renderFrame(root) {
1895    renderLoading(root);
1896
1897    const fixtureId = root.dataset.fixtureId || config.fixtureId || queryParam('s8_live_fixture_id') || '';
1898    const streamKey = decodeURIComponent(
1899      root.dataset.streamKey || config.streamKey || queryParam('s8_live_stream_key') || '',
1900    );
1901    const showChat = boolAttr(root.dataset.chat, true);
1902    const title = root.dataset.title || '';
1903
1904    try {
1905      let fixtures = [];
1906      let fixture = null;
1907      let preferredStreamKey = streamKey;
1908
1909      if (fixtureId) {
1910        fixture = await getFixture(fixtureId);
1911      } else {
1912        const [homeLive, fixtureList] = await Promise.all([
1913          getHomeLive().catch(() => null),
1914          getFixtures().catch(() => []),
1915        ]);
1916
1917        fixtures = fixtureList;
1918        fixture = homeLive || selectPrimaryFixture(fixtures, streamKey);
1919
1920        // Home-live API chỉ trả về stream đang live nên dropdown BLV chỉ thấy 1 người.
1921        // Lấy lại fixture đầy đủ theo ID để có toàn bộ OBS_stream_key giống trang chi tiết.
1922        if (fixture?.id) {
1923          const homeLiveStream = getDefaultStream(homeLive);
1924          preferredStreamKey = streamKey || homeLiveStream?.stream_key || '';
1925
1926          const listFixture = fixtures.find((item) => String(item?.id) === String(fixture.id));
1927          const fullFixture =
1928            listFixture && getActiveStreams(listFixture).length > getActiveStreams(fixture).length
1929              ? listFixture
1930              : await getFixture(fixture.id).catch(() => null);
1931
1932          if (fullFixture && getActiveStreams(fullFixture).length > getActiveStreams(fixture).length) {
1933            fixture = {
1934              ...fullFixture,
1935              time: fixture.time || fullFixture.time,
1936              time_type: fixture.time_type || fullFixture.time_type,
1937              left_score: fixture.left_score ?? fullFixture.left_score,
1938              right_score: fixture.right_score ?? fullFixture.right_score,
1939              home_scores: fixture.home_scores || fullFixture.home_scores,
1940              away_scores: fixture.away_scores || fullFixture.away_scores,
1941              reference_match_id: fixture.reference_match_id || fullFixture.reference_match_id,
1942            };
1943          }
1944        }
1945      }
1946
1947      if (!fixture) {
1948        throw new Error('Chưa có trận đấu để hiển thị.');
1949      }
1950
1951      const streams = getActiveStreams(fixture);
1952      const currentStream =
1953        streams.find((stream) => stream.stream_key === preferredStreamKey) || streams[0] || null;
1954      const resolvedStreamKey = currentStream?.stream_key || preferredStreamKey || `fixture-${fixture.id}`;
1955
1956      root.innerHTML = `
1957        <div class="s8lf-frame ${themeClass()} ${showChat ? '' : 's8lf-frame--player-only'}">
1958          ${
1959            title
1960              ? `<div class="s8lf-heading s8lf-frame__heading"><div><p>Bóng đá trực tiếp</p><h2>${escapeHtml(title)}</h2></div></div>`
1961              : ''
1962          }
1963          <div class="s8lf-live-layout s8lf-live-layout--frame">
1964            ${renderVideoShell(fixture, currentStream, fixtures)}
1965            ${showChat ? renderLiveSidePanel(fixture, resolvedStreamKey) : ''}
1966          </div>
1967        </div>
1968      `;
1969
1970      mountPlayer(root, currentStream);
1971      bindDetailControls(root);
1972      bindSidePanelTabs(root);
1973    } catch (error) {
1974      renderError(root, error);
1975    }
1976  }
1977
1978  roots.forEach((root) => {
1979    const view = root.dataset.view || config.view || 'list';
1980
1981    if (view === 'detail') {
1982      renderDetail(root);
1983      return;
1984    }
1985
1986    if (view === 'frame') {
1987      renderFrame(root);
1988      return;
1989    }
1990
1991    renderList(root);
1992  });
1993})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.