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, '<') 112 .replace(/>/g, '>') 113 .replace(/"/g, '"') 114 .replace(/'/g, '''); 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.