Spaces:
Sleeping
Sleeping
| /** | |
| * TXAPlayer v1.0 - TPhimX Next Generation | |
| * Base Logic: Markers, Subtitles (VTT/SRT), Replay, Skip Outro, Snapshot | |
| * v1.0: Optimized for Astro/Vite environment, integrated with KKPhim API | |
| */ | |
| // dashjs is loaded via CDN or global script | |
| var dashjs = window.dashjs || {}; | |
| var TXA_VERSION = 'v1.0'; | |
| // 🤖 TXA NEON CORE 2030 - STORYBOARD CONFIG | |
| var STORYBOARD_LEVELS = window.STORYBOARD_LEVELS || { | |
| L1: { grid: [10, 10], size: [160, 90], interval: 10, prefix: 'L1_M' }, | |
| L2: { grid: [5, 5], size: [240, 135], interval: 30, prefix: 'L2_M' }, | |
| L3: { grid: [3, 3], size: [320, 180], interval: 60, prefix: 'L3_M' } | |
| }; | |
| const txaStorage = { | |
| dbName: 'TXA_BLOB_CACHE', | |
| storeName: 'episodes', | |
| version: 1, | |
| _getDB() { | |
| return new Promise((resolve, reject) => { | |
| const request = indexedDB.open(this.dbName, this.version); | |
| request.onupgradeneeded = (e) => { | |
| const db = e.target.result; | |
| if (!db.objectStoreNames.contains(this.storeName)) { | |
| db.createObjectStore(this.storeName, { keyPath: 'id' }); | |
| } | |
| }; | |
| request.onsuccess = (e) => resolve(e.target.result); | |
| request.onerror = (e) => reject(e.target.error); | |
| }); | |
| }, | |
| async get(id) { | |
| try { | |
| const db = await this._getDB(); | |
| return new Promise((resolve, reject) => { | |
| const tx = db.transaction(this.storeName, 'readonly'); | |
| const store = tx.objectStore(this.storeName); | |
| const req = store.get(id); | |
| req.onsuccess = () => resolve(req.result); | |
| req.onerror = () => reject(req.error); | |
| }); | |
| } catch (e) { return null; } | |
| }, | |
| async set(id, data, m3u8Url) { | |
| try { | |
| const db = await this._getDB(); | |
| const tx = db.transaction(this.storeName, 'readwrite'); | |
| const store = tx.objectStore(this.storeName); | |
| store.put({ id, data, m3u8Url, timestamp: Date.now() }); | |
| return true; | |
| } catch (e) { return false; } | |
| } | |
| }; | |
| if (typeof window.TXAPlayer === 'undefined') { | |
| window.TXAPlayer = class { | |
| constructor(options) { | |
| const defaults = { | |
| containerId: 'player-container', | |
| videoUrl: '', | |
| posterUrl: '', | |
| title: 'TPhimX Media', | |
| markers: { intro: [0, 65], outro: [0, 0] }, | |
| subtitles: [], | |
| autoPlay: false, | |
| movieId: null, | |
| episodeId: null, | |
| movieSlug: null, | |
| episodeSlug: null, | |
| movieThumb: null, | |
| epCount: null, | |
| saveUrl: null, | |
| restoreUrl: null, | |
| csrfToken: null, | |
| nextEpisodeUrl: null, | |
| prevEpisodeUrl: null, | |
| autoSkipIntro: false, | |
| autoNextEpisode: false, | |
| hasSubtitles: true, | |
| isLoggedIn: false | |
| }; | |
| this.options = { ...defaults, ...options }; | |
| // Proxy external images | |
| if (this.options.posterUrl) this.options.posterUrl = this.getProxiedUrl(this.options.posterUrl); | |
| if (this.options.movieThumb) this.options.movieThumb = this.getProxiedUrl(this.options.movieThumb); | |
| if (this.options.previewUrl) this.options.previewUrl = this.getProxiedUrl(this.options.previewUrl); | |
| // Deep validate markers to support [0,0] fallback | |
| if (this.options.markers) { | |
| if (!this.options.markers.intro || (this.options.markers.intro[0] === 0 && this.options.markers.intro[1] === 0)) { | |
| this.options.markers.intro = defaults.markers.intro; | |
| } | |
| if (!this.options.markers.outro || (this.options.markers.outro[0] === 0 && this.options.markers.outro[1] === 0)) { | |
| this.options.markers.outro = defaults.markers.outro; | |
| } | |
| } | |
| this.settings = { | |
| volume: this.options.volume !== undefined ? this.options.volume : 1, | |
| muted: this.options.muted !== undefined ? this.options.muted : false, | |
| playbackRate: 1, | |
| autoSkipIntro: this.options.autoSkipIntro || false, | |
| autoNextEpisode: this.options.autoNextEpisode || false, | |
| showRealTime: true, | |
| clockFormat: 'H:i:s', | |
| ...this.loadSettings() | |
| }; | |
| let target = this.options.container || this.options.containerId; | |
| this.container = typeof target === 'string' | |
| ? (document.querySelector(target) || document.getElementById(target.replace(/^#/, ''))) | |
| : target; | |
| if (!this.container) { console.error('TXAPlayer: Container not found'); return; } | |
| // State | |
| this._isAutoNextTriggered = false; | |
| this._autoNextCountdownInterval = null; // Store countdown interval ID | |
| this._isRetryingVideo = false; // Track video retry state | |
| this.dashPlayer = null; | |
| this.video = null; | |
| this.wrapper = null; | |
| this.seekAccumulator = 0; | |
| this.seekDebounce = null; | |
| this.isDragging = false; | |
| this.currentSubCues = []; | |
| this.feedbackTimer = null; | |
| this.activeSubIdx = -1; | |
| this.destroyed = false; | |
| this._handlers = {}; | |
| // v5.8.0 New Features State | |
| this.isTheaterMode = false; | |
| this.isCinemaMode = false; | |
| this.isSpeedPreview = false; | |
| this.originalSpeed = 1; | |
| this.resumeShown = false; | |
| // v6.5.0 Preview Thumbnail State | |
| this._previewSpriteUrl = null; | |
| this._previewSpriteLoaded = false; | |
| this._previewLowUrl = null; | |
| this._previewLowLoaded = false; | |
| this._previewUpgradeTimer = null; | |
| // v6.5.2 - Turbo Storyboard (Neon Core 2030) | |
| this._useStoryboard = false; | |
| this._storyboardPath = null; | |
| this._currentStoryboardLevel = 'L1'; | |
| this._storyboardCache = new Set(); | |
| // v6.5.2 - Predictive MP4 Multi-Quality | |
| this._mp4Levels = []; | |
| this._lastMP4Url = null; | |
| this._mediaSetupDone = false; | |
| // v6.1.0 Ticker State | |
| this.tickerMax = Math.floor(Math.random() * 9) + 2; // Random 2-10 times | |
| this.tickerCount = 0; | |
| this.tickerNextTime = 5 + Math.random() * 20; // Faster first ticker for better visibility | |
| this._isTickerRunning = false; | |
| this._fixedTickerShown = false; | |
| this._endingTickerShown = false; | |
| this._loadProgress = 0; | |
| this._loadInterval = null; | |
| this.init(); | |
| } | |
| init() { | |
| // v6.3.0 - Grouped Console Log | |
| console.group('%c ✨ TPHIMX PLAYER %c ' + TXA_VERSION + ' %c ACTIVE ✨ ', | |
| 'background:#8b5cf6; color:#fff; font-weight:bold; padding:4px 0 4px 8px; border-radius:4px 0 0 4px;', | |
| 'background:#facc15; color:#000; font-weight:900; padding:4px 8px;', | |
| 'background:#8b5cf6; color:#fff; font-weight:bold; padding:4px 8px 4px 0; border-radius:0 4px 4px 0;' | |
| ); | |
| console.log('%cVersion: %c' + TXA_VERSION, 'color:#94a3b8', 'color:#fff; font-weight:bold'); | |
| console.log('%cMode: %cPlatinum Premium', 'color:#94a3b8', 'color:#fbbf24'); | |
| console.groupEnd(); | |
| if (window.txaPlayer) { | |
| console.log('[TXAPlayer] Destroying existing player instance...'); | |
| try { | |
| window.txaPlayer.destroy(); | |
| } catch (e) { | |
| console.error('[TXAPlayer] Error destroying old instance:', e); | |
| } | |
| } | |
| window.txaPlayer = this; | |
| window.TXAPlayer = TXAPlayer; | |
| this.container.classList.add('txa-player-skin'); | |
| this.tickerCount = 0; | |
| this.tickerMax = 5; | |
| this.tickerNextTime = 15; | |
| this._uiTimer = null; | |
| // Bind Methods | |
| this.showUI = this.showUI.bind(this); | |
| this.hideUI = this.hideUI.bind(this); | |
| this.injectStyles(); | |
| this.renderUI(); | |
| if (this.options.autoPlay) { | |
| this.setupMedia(); | |
| } else { | |
| // v6.6.2 - Support Lazy Click-to-Play | |
| const cp = this.container.querySelector('#txa-center-play'); | |
| if (cp) cp.classList.add('show'); | |
| // Still show controls but in non-playing state | |
| this.showUI(false); | |
| } | |
| this.bindEvents(); | |
| this.startClock(); // v6.4.0 Real-time Clock | |
| this.handleMobileEvents(); // v6.1.0 Mobile Events | |
| this.setupContextMenu(); | |
| this.setupShortcuts(); | |
| this.loadInitialSettings(); | |
| this.setupNewFeatures(); // v5.8.0 - Initialize new features | |
| this.updateTopTitle(); // Set initial title | |
| this.detectDevTools(); | |
| console.log( | |
| `%cSTOP! %cThis is a browser feature intended for developers. If someone told you to copy-paste something here to enable a feature or "hack" someone's account, it is a scam and will give them access to your account.\n\n%c[TXAPlayer ${TXA_VERSION}] %cSecurity Active`, | |
| 'color:red;font-family:system-ui;font-size:4rem;font-weight:bold;-webkit-text-stroke:1px black;', | |
| 'font-family:system-ui;font-size:1.5rem;font-weight:bold;', | |
| 'color:#8b5cf6;font-size:1.2rem;font-weight:bold;', | |
| 'color:gray;font-size:1.2rem;' | |
| ); | |
| } | |
| detectMobile() { | |
| // Use external TXA Detector if available | |
| if (typeof window.detectMobile === 'function') return window.detectMobile(); | |
| if (window.TXADetector) return window.TXADetector.isMobile(); | |
| // Standard minimal fallback for extreme cases where script fails to load | |
| return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent || '') || window.innerWidth <= 1024; | |
| } | |
| getProxiedUrl(url) { | |
| if (!url) return ''; | |
| if (url.includes('localhost') || url.startsWith('/') || url.startsWith('blob:')) return url; | |
| const blockedDomains = [ | |
| 'kkphim', | |
| 'phimimg', | |
| 'phimapi', | |
| 'tebi.io', | |
| 'stream', | |
| 'cloud', | |
| 'player', | |
| 'phim1280', | |
| 'ophim' | |
| ]; | |
| const shouldProxy = blockedDomains.some(domain => url.toLowerCase().includes(domain)); | |
| if (shouldProxy) { | |
| return `/api/proxy-media?url=${encodeURIComponent(url)}`; | |
| } | |
| return url; | |
| } | |
| handleMobileEvents() { | |
| if (!this.detectMobile()) return; | |
| this.container.classList.add('is-mobile'); | |
| const wrapper = this.wrapper; | |
| // State for Gestures | |
| let lastTap = 0; | |
| let tapTimer = null; | |
| let touchStartX = 0; | |
| let touchStartY = 0; | |
| let startVolume = 0; | |
| let isLongPress = false; | |
| let pressTimer = null; | |
| let isScrubbing = false; | |
| let isVolumeGesture = false; | |
| // --- 1. LONG PRESS (2x SPEED) & GESTURE START --- | |
| wrapper.addEventListener('touchstart', (e) => { | |
| // Ignore if touching controls or panels | |
| if (e.target.closest('.txa-controls, .txa-panel, .txa-ctx, .txa-replay-overlay, .txa-skip-btn, .txa-resume-overlay, .txa-next-countdown, .txa-loader, .txa-stats-panel, button, i, .txa-btn')) return; | |
| const touch = e.touches[0]; | |
| touchStartX = touch.clientX; | |
| touchStartY = touch.clientY; | |
| startVolume = this.video.volume; | |
| isLongPress = false; | |
| isVolumeGesture = false; | |
| // Start Long Press Timer (YouTube Style 2x Speed) | |
| pressTimer = setTimeout(() => { | |
| if (!this.video.paused && !isScrubbing && !isVolumeGesture) { | |
| isLongPress = true; | |
| this.startSpeedPreview(); | |
| if (window.navigator.vibrate) window.navigator.vibrate(20); | |
| } | |
| }, 500); | |
| }, { passive: true }); | |
| wrapper.addEventListener('touchend', (e) => { | |
| if (e.target.closest('.txa-controls, .txa-panel, .txa-ctx, .txa-replay-overlay, .txa-skip-btn, .txa-resume-overlay, .txa-next-countdown, .txa-loader, .txa-stats-panel, button, i, .txa-btn')) return; | |
| clearTimeout(pressTimer); | |
| if (isLongPress) { | |
| isLongPress = false; | |
| this.stopSpeedPreview(); | |
| e.preventDefault(); | |
| return; | |
| } | |
| if (isVolumeGesture) { | |
| isVolumeGesture = false; | |
| return; | |
| } | |
| const touchEnd = e.changedTouches[0]; | |
| const dx = Math.abs(touchEnd.clientX - touchStartX); | |
| const dy = Math.abs(touchEnd.clientY - touchStartY); | |
| if (dx > 20 || dy > 20) return; | |
| const currentTime = new Date().getTime(); | |
| const tapLength = currentTime - lastTap; | |
| if (tapLength < 300 && tapLength > 0) { | |
| // DOUBLE TAP detection | |
| clearTimeout(tapTimer); | |
| const rect = wrapper.getBoundingClientRect(); | |
| const x = touchEnd.clientX - rect.left; | |
| // 25% Left (Rewind), 25% Right (Forward), 50% Center (Toggle Play) | |
| if (x < rect.width * 0.25) { | |
| this.seekAccumulated(-10); | |
| } else if (x > rect.width * 0.75) { | |
| this.seekAccumulated(10); | |
| } else { | |
| this.togglePlay(); | |
| } | |
| if (e.cancelable) e.preventDefault(); | |
| } else { | |
| // SINGLE TAP logic - Highly sensitive center zone (60% width) | |
| tapTimer = setTimeout(() => { | |
| const rect = wrapper.getBoundingClientRect(); | |
| const x = touchEnd.clientX - rect.left; | |
| if (x > rect.width * 0.2 && x < rect.width * 0.8) { | |
| this.togglePlay(); | |
| } else { | |
| if (wrapper.classList.contains('active-ui')) this.hideUI(); | |
| else this.showUI(); | |
| } | |
| }, 200); // Shorter delay for better responsiveness | |
| } | |
| lastTap = currentTime; | |
| if (e.cancelable) e.preventDefault(); | |
| }, { passive: false }); | |
| wrapper.addEventListener('touchmove', (e) => { | |
| if (isScrubbing) return; | |
| const touch = e.touches[0]; | |
| const dx = touch.clientX - touchStartX; | |
| const dy = touch.clientY - touchStartY; | |
| // Fullscreen Gestures (Volume on Right Side) | |
| const isFS = !!(document.fullscreenElement || document.webkitIsFullScreen || this.video?.webkitDisplayingFullscreen || this.wrapper.classList.contains('fullscreen-mode')); | |
| if (isFS && !isLongPress && Math.abs(dy) > 20) { | |
| const rect = wrapper.getBoundingClientRect(); | |
| // Right side check | |
| if (touchStartX > rect.width * 0.5) { | |
| if (!isVolumeGesture && Math.abs(dy) > Math.abs(dx)) { | |
| isVolumeGesture = true; | |
| clearTimeout(pressTimer); | |
| } | |
| if (isVolumeGesture) { | |
| if (e.cancelable) e.preventDefault(); | |
| const sensitivity = 1.5; // Swipe height = 1.5x range | |
| const delta = (touchStartY - touch.clientY) / (rect.height / sensitivity); | |
| const newVol = Math.max(0, Math.min(1, startVolume + delta)); | |
| this.video.volume = newVol; | |
| this.video.muted = false; | |
| this.updateVolUI(); | |
| this.showFeedback('volume'); | |
| } | |
| } | |
| } else if (Math.abs(dx) > 20 || Math.abs(dy) > 20) { | |
| clearTimeout(pressTimer); | |
| } | |
| }, { passive: false }); | |
| // --- 2. MOBILE PROGRESS SCRUBBING --- | |
| const pWrap = this.container.querySelector('#txa-progress'); | |
| if (pWrap) { | |
| pWrap.addEventListener('touchstart', (e) => { | |
| isScrubbing = true; | |
| pWrap.classList.add('dragging'); | |
| this.showUI(false); | |
| this.updateMobileScrub(e, pWrap); | |
| }, { passive: true }); | |
| pWrap.addEventListener('touchmove', (e) => { | |
| if (isScrubbing) { | |
| if (e.cancelable) e.preventDefault(); | |
| this.updateMobileScrub(e, pWrap); | |
| } | |
| }, { passive: false }); | |
| pWrap.addEventListener('touchend', (e) => { | |
| if (isScrubbing) { | |
| isScrubbing = false; | |
| pWrap.classList.remove('dragging'); | |
| const rect = pWrap.getBoundingClientRect(); | |
| const x = e.changedTouches[0].clientX - rect.left; | |
| const pct = Math.max(0, Math.min(1, x / rect.width)); | |
| this.safeSeek(pct * this.video.duration); | |
| const timeOverlay = this.container.querySelector('.txa-mobile-seek-time'); | |
| if (timeOverlay) timeOverlay.style.display = 'none'; | |
| this.showUI(); | |
| } | |
| }, { passive: true }); | |
| } | |
| } | |
| updateMobileScrub(e, pWrap, timeOverlay) { | |
| if (!this.video.duration) return; | |
| const rect = pWrap.getBoundingClientRect(); | |
| const clientX = e.touches ? e.touches[0].clientX : e.clientX; | |
| const x = clientX - rect.left; | |
| const pct = Math.max(0, Math.min(1, x / rect.width)); | |
| const targetTime = pct * this.video.duration; | |
| // Update Visual Bar immediately | |
| const playedBar = this.container.querySelector('#txa-played'); | |
| if (playedBar) playedBar.style.width = (pct * 100) + '%'; | |
| // Update Time Preview | |
| if (timeOverlay) { | |
| timeOverlay.style.display = 'block'; | |
| timeOverlay.innerText = this.formatTime(targetTime, this.video.duration >= 3600); | |
| } | |
| } | |
| /** | |
| * YouTube-style buffer bar update | |
| * Called every 1s and on FRAG_LOADED to reflect pre-loaded segments | |
| */ | |
| _updateBufferBar() { | |
| if (!this.video) return; | |
| const duration = this.video.duration; | |
| if (!duration || !isFinite(duration)) return; | |
| const bufBar = this.container.querySelector('#txa-buffer'); | |
| if (!bufBar) return; | |
| const buffered = this.video.buffered; | |
| const current = this.video.currentTime; | |
| let bufferEnd = 0; | |
| // Find the buffer range that contains or is closest ahead of current time | |
| for (let i = 0; i < buffered.length; i++) { | |
| if (current >= buffered.start(i) - 0.5 && current <= buffered.end(i)) { | |
| bufferEnd = buffered.end(i); | |
| break; | |
| } | |
| } | |
| // Fallback: use the largest buffered end | |
| if (bufferEnd === 0 && buffered.length > 0) { | |
| bufferEnd = buffered.end(buffered.length - 1); | |
| } | |
| const bufPct = (bufferEnd / duration) * 100; | |
| bufBar.style.width = Math.min(100, bufPct).toFixed(2) + '%'; | |
| } | |
| updateProgress() { | |
| if (!this.video || this.isDragging) return; | |
| const current = this.video.currentTime; | |
| const duration = this.video.duration; | |
| if (!duration) return; | |
| const pct = (current / duration) * 100; | |
| const playedBar = this.container.querySelector('#txa-played'); | |
| if (playedBar) playedBar.style.width = pct + '%'; | |
| // Update Buffer Progress (YouTube-style) | |
| this._updateBufferBar(); | |
| const currText = this.container.querySelector('#txa-curr') || this.container.querySelector('#txa-time'); | |
| if (currText) { | |
| if (currText.id === 'txa-time') { | |
| currText.innerText = this.formatTimeDisplay(); | |
| } else { | |
| currText.innerText = this.formatTime(current, duration >= 3600); | |
| } | |
| } | |
| this.checkMarkers(current); | |
| this.checkTicker(); | |
| this.renderSubtitles(); | |
| } | |
| injectStyles() { | |
| if (document.getElementById('txa-css')) return; | |
| const s = document.createElement('style'); | |
| s.id = 'txa-css'; | |
| s.textContent = ` | |
| @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&display=swap'); | |
| @import url('https://fonts.cdnfonts.com/css/digital-7-mono'); | |
| /* ==================== BASE PLAYER STYLES (MERGED) ==================== */ | |
| .txa-player { position: relative; width: 100%; height: 100%; background: #000; border-radius: 12px; overflow: hidden; font-family: 'Outfit', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; user-select: none; -webkit-user-select: none; overscroll-behavior-x: none; } | |
| .txa-video-container { position: relative; width: 100%; height: 100%; background: #000; display: flex; align-items: center; justify-content: center; } | |
| .txa-video { width: 100%; height: 100%; object-fit: contain; } | |
| .txa-ambient-light { position: absolute; top: 0; left: 0; width: 50px; height: 50px; pointer-events: none; opacity: 0; } | |
| .txa-subtitle-overlay { position: absolute; bottom: 80px; left: 50%; transform: translateX(-50%); max-width: 80%; text-align: center; color: #fff; font-size: 24px; font-weight: 600; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.9); pointer-events: none; z-index: 10; line-height: 1.4; transition: bottom 0.3s ease; } | |
| .txa-controls.visible~.txa-subtitle-overlay { bottom: 100px; } | |
| .txa-branding { position: absolute; top: 20px; right: 20px; width: 60px; height: 60px; opacity: 0.8; transition: opacity 0.3s ease; z-index: 5; pointer-events: none; } | |
| .txa-branding.playing { opacity: 0.3; } | |
| .txa-branding.visible { opacity: 0.8; } | |
| .txa-logo { width: 100%; height: 100%; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5)); } | |
| .txa-loading-spinner { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: none; z-index: 20; pointer-events: none; } | |
| .txa-loading-spinner.active { display: block; } | |
| .spinner { width: 50px; height: 50px; border: 3px solid rgba(255, 255, 255, 0.1); border-top-color: #6366f1; border-radius: 50%; animation: spin 0.8s linear infinite; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .txa-play-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3); opacity: 0; transition: opacity 0.3s ease; z-index: 15; pointer-events: none; } | |
| .txa-play-overlay.visible { opacity: 1; pointer-events: auto; } | |
| .txa-big-play-btn { width: 80px; height: 80px; background: rgba(255, 255, 255, 0.9); border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.3s ease; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); } | |
| .txa-big-play-btn svg { width: 32px; height: 32px; color: #6366f1; margin-left: 4px; } | |
| .txa-big-play-btn:hover { transform: scale(1.1); background: #fff; } | |
| .txa-controls { position: absolute; bottom: 0; left: 0; width: 100%; background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, transparent 100%); padding: 20px; opacity: 0; visibility: hidden; transition: all 0.3s ease; z-index: 25; } | |
| .txa-controls.visible { opacity: 1; visibility: visible; } | |
| .txa-progress-container { position: relative; width: 100%; height: 6px; margin-bottom: 20px; cursor: pointer; touch-action: none; } | |
| .txa-progress-bar { position: relative; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.2); border-radius: 3px; overflow: visible; transition: height 0.1s ease; } | |
| .txa-progress-container:hover .txa-progress-bar { height: 8px; } | |
| .txa-progress-buffer { position: absolute; top: 0; left: 0; height: 100%; background: rgba(255, 255, 255, 0.3); transition: width 0.1s linear; border-radius: 3px; } | |
| .txa-progress-played { position: absolute; top: 0; left: 0; height: 100%; background: linear-gradient(90deg, #6366f1, #8b5cf6); transition: width 0.05s linear; border-radius: 3px; box-shadow: 0 0 10px rgba(99, 102, 241, 0.5); } | |
| .txa-progress-handle { position: absolute; top: 50%; right: -7px; width: 14px; height: 14px; background: #fff; border-radius: 50%; transform: translateY(-50%) scale(0); transition: transform 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); z-index: 2; } | |
| .txa-progress-container:hover .txa-progress-handle, .txa-progress-container.dragging .txa-progress-handle { transform: translateY(-50%) scale(1); } | |
| .txa-progress-tooltip { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.9); color: #fff; padding: 6px 10px; border-radius: 6px; font-size: 13px; white-space: nowrap; opacity: 0; visibility: hidden; transition: all 0.2s ease; pointer-events: none; font-weight: 500; } | |
| .txa-progress-container:hover .txa-progress-tooltip { opacity: 1; visibility: visible; bottom: 15px; } | |
| .txa-controls-row { display: flex; align-items: center; justify-content: space-between; } | |
| .txa-controls-left, .txa-controls-right { display: flex; align-items: center; gap: 15px; } | |
| .txa-time { display: flex; align-items: center; gap: 5px; color: rgba(255, 255, 255, 0.9); font-size: 13px; font-weight: 600; letter-spacing: 0.3px; min-width: 110px; justify-content: center; font-family: 'JetBrains Mono', monospace; } | |
| .txa-time-separator { opacity: 0.5; font-size: 12px; } | |
| .txa-btn { width: 44px; height: 44px; background: transparent; border: none; border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; color: rgba(255, 255, 255, 0.9); position: relative; overflow: visible; } | |
| .txa-btn:hover { background: rgba(255, 255, 255, 0.15); color: #fff; transform: translateY(-2px); } | |
| .txa-btn:active { transform: translateY(0); } | |
| .txa-btn svg { width: 24px; height: 24px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)); } | |
| .txa-btn-play .icon-pause { display: none; } | |
| .txa-btn-play.playing .icon-play { display: none; } | |
| .txa-btn-play.playing .icon-pause { display: block; } | |
| .txa-volume-container { position: relative; display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; } | |
| .txa-volume-slider { position: absolute; bottom: 50px; left: 50%; transform: translateX(-50%); width: 32px; height: 0; background: rgba(15, 23, 42, 0.9); backdrop-filter: blur(10px); border-radius: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: 0; visibility: hidden; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; padding: 10px 0; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); z-index: 50; } | |
| .txa-volume-container:hover .txa-volume-slider { height: 150px; opacity: 1; visibility: visible; bottom: 55px; } | |
| .txa-volume-input { width: 110px; height: 6px; -webkit-appearance: none; appearance: none; background: rgba(255, 255, 255, 0.2); border-radius: 3px; cursor: pointer; outline: none; transform: rotate(-90deg); margin: 55px 0; } | |
| .txa-volume-input::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; background: #fff; border-radius: 50%; cursor: pointer; transition: transform 0.1s; } | |
| .txa-volume-input::-webkit-slider-thumb:hover { transform: scale(1.2); } | |
| .txa-settings-panel { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.95); width: 380px; max-height: 85vh; background: rgba(15, 23, 42, 0.95); backdrop-filter: blur(30px) saturate(180%); border: 1px solid rgba(255, 255, 255, 0.15); box-shadow: 0 40px 100px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.05); border-radius: 24px; padding: 30px; overflow-y: auto; opacity: 0; visibility: hidden; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); z-index: 100; } | |
| .txa-panel-close { position: absolute; top: 15px; right: 15px; width: 32px; height: 32px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 50%; color: #fff; font-size: 20px; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; z-index: 10; } | |
| .txa-panel-close:hover { background: #ef4444; border-color: #ef4444; transform: rotate(90deg); } | |
| .txa-settings-panel.active { opacity: 1; visibility: visible; transform: translate(-50%, -50%) scale(1); } | |
| .txa-settings-content { display: flex; flex-direction: column; gap: 24px; } | |
| .txa-setting-group { display: flex; flex-direction: column; gap: 12px; } | |
| .txa-setting-label { color: #94a3b8; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-left: 4px; } | |
| .txa-setting-options { display: flex; flex-wrap: wrap; gap: 8px; } | |
| .txa-option-btn { padding: 8px 16px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; color: #e2e8f0; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; } | |
| .txa-option-btn:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.2); } | |
| .txa-option-btn.active { background: linear-gradient(135deg, #6366f1, #8b5cf6); border-color: transparent; color: #fff; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); } | |
| .txa-subtitle-style-group { display: none; border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 20px; } | |
| .txa-subtitle-style-group.active { display: flex; } | |
| .txa-style-controls { display: grid; grid-template-columns: 1fr; gap: 16px; } | |
| .txa-style-control { display: grid; grid-template-columns: 80px 1fr; align-items: center; gap: 12px; } | |
| .txa-style-control label { color: #cbd5e1; font-size: 13px; } | |
| .txa-style-control input[type="range"], .txa-style-control input[type="color"], .txa-style-control select { width: 100%; background: rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; padding: 6px 10px; color: #fff; font-size: 13px; cursor: pointer; } | |
| .txa-stats-panel { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.95); width: 400px; max-height: 80vh; background: rgba(15, 23, 42, 0.9); backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; padding: 24px; overflow-y: auto; opacity: 0; visibility: hidden; transition: all 0.2s ease; z-index: 100; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); } | |
| .txa-stats-panel.active { opacity: 1; visibility: visible; transform: translate(-50%, -50%) scale(1); } | |
| .txa-stats-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); font-family: 'JetBrains Mono', monospace; } | |
| .txa-stats-label { color: #94a3b8; font-size: 12px; } | |
| .txa-stats-value { color: #a5b4fc; font-size: 12px; } | |
| .txa-lockdown-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.98); display: flex; align-items: center; justify-content: center; z-index: 200; opacity: 0; visibility: hidden; transition: all 0.3s ease; } | |
| .txa-lockdown-overlay.active { opacity: 1; visibility: visible; } | |
| .txa-context-menu { position: fixed; z-index: 9999; min-width: 220px; background: rgba(30, 41, 59, 0.95); backdrop-filter: blur(16px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 12px; padding: 6px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); opacity: 0; visibility: hidden; transform: scale(0.95); transform-origin: top left; transition: opacity 0.15s ease, transform 0.15s ease; } | |
| .txa-context-menu.active { opacity: 1; visibility: visible; transform: scale(1); } | |
| .txa-ctx-header { padding: 12px; margin-bottom: 6px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; display: flex; align-items: center; gap: 10px; } | |
| .txa-ctx-header span { color: #fff; font-weight: 600; font-size: 14px; } | |
| .txa-ctx-item { padding: 10px 12px; color: #cbd5e1; font-size: 13px; border-radius: 6px; transition: all 0.1s; display: flex; align-items: center; gap: 10px; } | |
| .txa-ctx-item:hover { background: rgba(99, 102, 241, 0.2); color: #fff; } | |
| .txa-ctx-item-sub { margin-left: auto; font-size: 11px; opacity: 0.6; background: rgba(255, 255, 255, 0.1); padding: 2px 6px; border-radius: 4px; } | |
| .txa-ctx-divider { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 4px 0; } | |
| [data-tooltip] { position: relative; } | |
| [data-tooltip]:hover::after { content: attr(data-tooltip); position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%); background: rgba(15, 23, 42, 0.95); color: #fff; padding: 6px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap; margin-bottom: 10px; opacity: 0; visibility: hidden; transition: all 0.2s ease; pointer-events: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); border: 1px solid rgba(255, 255, 255, 0.1); z-index: 50; } | |
| [data-tooltip]:hover::after { opacity: 1; visibility: visible; } | |
| @media (max-width: 768px) { | |
| .txa-controls { padding: 12px 14px; } | |
| .txa-controls-left, .txa-controls-right { gap: 6px; } | |
| .txa-btn { width: 44px; height: 44px; } | |
| .txa-btn svg { width: 20px; height: 20px; } | |
| .txa-time { font-size: 12px; gap: 3px; } | |
| .txa-branding { width: 40px; height: 40px; top: 12px; right: 12px; } | |
| .txa-big-play-btn { width: 64px; height: 64px; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } | |
| .txa-big-play-btn svg { width: 26px; height: 26px; } | |
| .txa-subtitle-overlay { font-size: 16px; max-width: 90%; bottom: 60px; } | |
| .txa-controls.visible~.txa-subtitle-overlay { bottom: 80px; } | |
| .txa-progress-container { margin-bottom: 14px; } | |
| .txa-settings-panel { width: 90vw; max-width: 340px; padding: 20px; border-radius: 18px; } | |
| .txa-option-btn { padding: 7px 12px; font-size: 12px; } | |
| .txa-stats-panel { width: 90vw; max-width: 340px; padding: 18px; } | |
| .txa-volume-slider { display: none !important; } | |
| .is-mobile .txa-volume-slider-wrap { display: none !important; } | |
| .txa-volume-container:hover .txa-volume-slider { display: none !important; } | |
| .txa-context-menu { min-width: 180px; max-width: calc(100vw - 40px); } | |
| .txa-progress-tooltip { font-size: 11px; padding: 4px 8px; } | |
| .spinner { width: 40px; height: 40px; } | |
| } | |
| @media (max-width: 480px) { | |
| .txa-controls { padding: 8px 10px; } | |
| .txa-controls-left, .txa-controls-right { gap: 2px; } | |
| .txa-btn { width: 34px; height: 34px; border-radius: 6px; } | |
| .txa-btn svg { width: 18px; height: 18px; } | |
| .txa-time { font-size: 11px; } | |
| .txa-branding { width: 32px; height: 32px; top: 8px; right: 8px; } | |
| .txa-btn-pro { display: none !important; } | |
| .txa-time { min-width: 90px; font-size: 10px; } | |
| .txa-big-play-btn { width: 56px; height: 56px; position: absolute; top: calc(50% - 28px); left: calc(50% - 28px); } | |
| .txa-big-play-btn svg { width: 22px; height: 22px; } | |
| .txa-subtitle-overlay { font-size: 13px; max-width: 94%; bottom: 50px; text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.9); } | |
| .txa-controls.visible~.txa-subtitle-overlay { bottom: 65px; } | |
| .txa-progress-container { margin-bottom: 12px; height: 4px; padding-top: 15px; padding-bottom: 15px; background-clip: content-box; } | |
| .txa-progress-bar { height: 100%; border-radius: 2px; } | |
| .txa-progress-bg { background: rgba(255, 255, 255, 0.2); } | |
| .txa-progress-handle { width: 14px; height: 14px; right: -7px; transform: translateY(-50%) scale(0); transition: transform 0.1s; } | |
| .txa-progress-container.dragging .txa-progress-handle, .active-ui .txa-progress-handle { transform: translateY(-50%) scale(1); } | |
| .txa-speed-overlay { position: absolute; top: 25%; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--txa-glass); backdrop-filter: blur(20px); padding: 12px 24px; border-radius: 100px; color: #fff; font-size: 16px; font-weight: 800; display: flex; align-items: center; gap: 12px; opacity: 0; pointer-events: none; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); z-index: 500; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8); border: 1px solid var(--txa-border); } | |
| .txa-speed-overlay.show { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| .txa-speed-overlay i { font-size: 12px; } | |
| .txa-mobile-seek-time { position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.8); color: #fff; padding: 6px 12px; border-radius: 8px; font-size: 14px; font-weight: 700; display: none; z-index: 60; border: 1px solid rgba(255, 255, 255, 0.1); } | |
| .txa-progress-container.dragging .txa-mobile-seek-time { display: block; } | |
| .txa-settings-panel { width: 94vw; max-width: none; padding: 16px; border-radius: 14px; max-height: 75vh; } | |
| .txa-setting-group { gap: 8px; } | |
| .txa-setting-label { font-size: 10px; } | |
| .txa-option-btn { padding: 6px 10px; font-size: 11px; } | |
| .txa-stats-panel { width: 94vw; max-width: none; padding: 14px; border-radius: 14px; } | |
| .txa-stats-row { padding: 8px 0; } | |
| .txa-stats-label, .txa-stats-value { font-size: 11px; } | |
| .spinner { width: 34px; height: 34px; border-width: 2px; } | |
| .txa-panel-close { width: 28px; height: 28px; font-size: 16px; top: 10px; right: 10px; } | |
| .txa-style-control { grid-template-columns: 60px 1fr; gap: 8px; } | |
| .txa-style-control label { font-size: 11px; } | |
| } | |
| :root { | |
| --txa-brand: #8b5cf6; | |
| --txa-brand-glow: rgba(139,92,246,0.6); | |
| --txa-glass: rgba(12,12,18,0.85); | |
| --txa-glass-light: rgba(30,30,40,0.9); | |
| --txa-border: rgba(255,255,255,0.12); | |
| --txa-text: #f1f5f9; | |
| --txa-intro: #fbbf24; | |
| --txa-outro: #ef4444; | |
| --txa-brand-light: #a78bfa; | |
| } | |
| /* BRANDING elements - LUXURY STYLE */ | |
| .txa-brand-logo { | |
| position: absolute; top: 25px; left: 25px; z-index: 150; | |
| pointer-events: none; opacity: 0.6; transform: translateY(0); | |
| transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); | |
| filter: drop-shadow(0 4px 12px rgba(0,0,0,0.8)); | |
| } | |
| /* REAL-TIME CLOCK - v6.5.1 Top-Down Dynamic */ | |
| .txa-realtime-clock { | |
| position: absolute; top: 25px; right: 25px; z-index: 2147483647; | |
| background: rgba(15, 15, 20, 0.7); backdrop-filter: blur(25px); | |
| border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 50px; | |
| padding: 8px 16px; font-family: 'Digital-7 Mono', 'Outfit', sans-serif; | |
| font-size: 16px; font-weight: 600; color: #fff; | |
| display: none; align-items: center; gap: 8px; | |
| box-shadow: 0 10px 40px rgba(0,0,0,0.6); | |
| transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); | |
| pointer-events: none; | |
| letter-spacing: 1px; | |
| will-change: top, transform; | |
| } | |
| .txa-realtime-clock.active { display: flex; } | |
| .active-ui .txa-realtime-clock { top: 85px; } /* Slide DOWN to avoid Top Info Bar */ | |
| .txa-realtime-clock i { font-size: 12px; color: var(--txa-brand); opacity: 0.8; } | |
| @media (max-width: 768px) { | |
| .txa-realtime-clock { font-size: 15px; padding: 6px 12px; top: 15px; right: 15px; } | |
| } | |
| .txa-wrapper.video-playing.active-ui .txa-brand-logo { opacity: 1; } | |
| .txa-wrapper.video-playing:not(.active-ui) .txa-brand-logo { opacity: 0.25; } | |
| .txa-logo-text { font-family: 'Outfit', sans-serif; font-weight: 900; font-size: 24px; letter-spacing: -1px; } | |
| .txa-logo-t { color: var(--txa-brand); } | |
| .txa-logo-phimx { color: #fff; } | |
| /* Smooth Center Play/Pause Animation */ | |
| .txa-center-feedback { | |
| position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.5); | |
| width: 80px; height: 80px; background: rgba(0,0,0,0.4); border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; | |
| opacity: 0; pointer-events: none; transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
| z-index: 200; border: 1px solid rgba(255,255,255,0.1); backdrop-filter: blur(10px); | |
| } | |
| .txa-center-feedback.animate { opacity: 1; transform: translate(-50%, -50%) scale(1.2); } | |
| .txa-center-feedback.animate-out { opacity: 0; transform: translate(-50%, -50%) scale(1.5); } | |
| .txa-center-feedback i { font-size: 32px; color: #fff; text-shadow: 0 0 15px var(--txa-brand-glow); } | |
| /* Click-to-Play Center Button v6.6.2 */ | |
| .txa-center-play { | |
| position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.9); | |
| width: 90px; height: 90px; background: rgba(139, 92, 246, 0.4); backdrop-filter: blur(25px); | |
| border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; | |
| color: #fff; font-size: 36px; cursor: pointer; | |
| opacity: 0; visibility: hidden; z-index: 150; | |
| transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); | |
| box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 40px var(--txa-brand-glow); | |
| } | |
| .txa-center-play.show { opacity: 1; visibility: visible; transform: translate(-50%, -50%) scale(1); } | |
| .txa-center-play:hover { transform: translate(-50%, -50%) scale(1.15); background: var(--txa-brand); border-color: #fff; } | |
| .txa-center-play i { margin-left: 6px; filter: drop-shadow(0 0 15px #fff); } | |
| .txa-watermark { | |
| position: absolute; bottom: 100px; right: 25px; z-index: 80; | |
| pointer-events: none; opacity: 0.3; font-size: 11px; font-weight: 800; | |
| color: rgba(255,255,255,0.4); text-transform: uppercase; letter-spacing: 2px; | |
| font-family: 'Outfit', sans-serif; transition: opacity 0.3s; | |
| } | |
| .txa-watermark span { color: var(--txa-brand); } | |
| .txa-wrapper.video-playing:not(.active-ui) .txa-watermark { opacity: 0.15; } | |
| .txa-player-skin { position:absolute; inset:0; background:#000; font-family:'Outfit',sans-serif; color:var(--txa-text); user-select:none; overflow:hidden; } | |
| .txa-wrapper { position:relative; width:100%; height:100%; outline:none; container-type: inline-size; container-name: player; } | |
| .txa-video { width:100%; height:100%; object-fit:contain; } | |
| /* LOADER - Enhanced */ | |
| .txa-loader { position:absolute; inset:0; background:rgba(0,0,0,0.85); z-index:95; display:flex; align-items:center; justify-content:center; flex-direction:column; gap:0; backdrop-filter:blur(8px); pointer-events: none; } | |
| .txa-loader.hidden { display:none; } | |
| .txa-loader-content { display:flex; flex-direction:column; align-items:center; gap:12px; max-width:380px; width:90%; } | |
| .txa-spinner { width:52px; height:52px; border:3px solid rgba(255,255,255,0.08); border-top-color:var(--txa-brand); border-radius:50%; animation:txaspin 0.7s linear infinite; } | |
| .txa-load-pct-row { display:flex; align-items:baseline; gap:10px; justify-content:center; } | |
| .txa-load-pct { font-size:28px; font-weight:900; color:#fff; text-shadow: 0 0 15px var(--txa-brand-glow); font-variant-numeric:tabular-nums; } | |
| .txa-load-speed { font-size:12px; font-weight:700; color:var(--txa-brand); background:rgba(139,92,246,0.15); padding:3px 10px; border-radius:20px; font-variant-numeric:tabular-nums; letter-spacing:0.3px; white-space:nowrap; } | |
| .txa-load-text { font-size:12px; font-weight:500; color:rgba(255,255,255,0.5); letter-spacing:0.3px; text-align:center; } | |
| .txa-load-info { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:20px; width:100%; } | |
| .txa-load-info-tag { | |
| font-size:11px; font-weight:700; color:#fff; | |
| background:rgba(255,255,255,0.06); backdrop-filter:blur(10px); | |
| padding:6px 14px; border-radius:10px; | |
| border:1px solid rgba(255,255,255,0.1); | |
| text-transform:uppercase; letter-spacing:0.8px; | |
| display:flex; align-items:center; gap:8px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); | |
| transition: transform 0.3s ease; | |
| } | |
| .txa-load-info-tag i { font-size:12px; color: var(--txa-brand-light); } | |
| .txa-load-info-tag .tag-val { color:#fff; font-weight: 800; } | |
| .txa-load-info-tag.bg-warning { border-color: rgba(234, 179, 8, 0.4); color: #facc15; } | |
| .txa-load-info-tag.bg-warning i { color: #facc15; } | |
| @container player (max-width: 500px) { | |
| .txa-load-info { flex-direction: column; align-items: center; gap: 10px; } | |
| .txa-load-info-tag { width: 85%; justify-content: center; font-size: 10px; padding: 10px; } | |
| .txa-load-pct { font-size: 42px; } | |
| } | |
| .txa-load-bar { width:100%; height:5px; background:rgba(255,255,255,0.1); border-radius:10px; overflow:hidden; margin-top:10px; } | |
| .txa-load-bar-fill { height:100%; width:0%; background:linear-gradient(90deg, var(--txa-brand), #f472b6); border-radius:10px; transition:width 0.4s cubic-bezier(0.4, 0, 0.2, 1); box-shadow: 0 0 15px var(--txa-brand-glow); } | |
| @keyframes txaspin { to { transform:rotate(360deg); } } | |
| /* STATS PANEL - Premium Centered */ | |
| .txa-stats-panel { | |
| position:absolute; top:50%; left:50%; transform:translate(-50%, -50%) scale(0.9); | |
| z-index:2147483647; | |
| background:rgba(10, 10, 15, 0.9); backdrop-filter:blur(30px); | |
| border:1px solid rgba(255,255,255,0.15); border-radius:24px; | |
| padding:25px; min-width:320px; max-width:450px; | |
| font-family:'Outfit', system-ui, sans-serif; | |
| display:none; pointer-events:auto; | |
| box-shadow: 0 30px 100px rgba(0,0,0,0.9), 0 0 40px rgba(139,92,246,0.2); | |
| transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| } | |
| .txa-stats-panel.active { display:block; animation:txaStatsIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; } | |
| @keyframes txaStatsIn { | |
| from { opacity:0; transform:translate(-50%, -45%) scale(0.95); } | |
| to { opacity:1; transform:translate(-50%, -50%) scale(1); } | |
| } | |
| @keyframes txaShortcutsIn { | |
| from { opacity:0; transform:translateY(20px) scale(0.95); } | |
| to { opacity:1; transform:translateY(0) scale(1); } | |
| } | |
| .txa-shortcuts-panel { | |
| position: absolute; inset: 0; | |
| display: flex; align-items: center; justify-content: center; | |
| pointer-events: none; z-index: 2147483647; | |
| } | |
| .txa-shortcuts-panel.active { pointer-events: auto; } | |
| .txa-shortcuts-panel .txa-shortcuts-content { | |
| background:rgba(10, 10, 15, 0.9); backdrop-filter:blur(30px); | |
| border:1px solid rgba(255,255,255,0.15); border-radius:24px; | |
| padding:25px; min-width:320px; max-width:500px; | |
| font-family:'Outfit', system-ui, sans-serif; | |
| box-shadow: 0 30px 100px rgba(0,0,0,0.9), 0 0 40px rgba(139,92,246,0.2); | |
| opacity: 0; /* Start hidden for animation */ | |
| animation: txaShortcutsIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; | |
| } | |
| .txa-shortcuts-table { width: 100%; border-collapse: separate; border-spacing: 0 8px; } | |
| .txa-shortcuts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } | |
| @media (max-width: 600px) { .txa-shortcuts-grid { grid-template-columns: 1fr; } } | |
| .txa-shortcuts-table th { text-align: left; font-size: 11px; text-transform: uppercase; color: var(--txa-brand); letter-spacing: 1px; padding: 0 10px 10px; opacity: 0.8; } | |
| .txa-shortcuts-table td { padding: 8px 10px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.05); } | |
| .txa-shortcuts-table td:first-child { border-radius: 10px 0 0 10px; border-right: none; font-size: 13px; color: #cbd5e1; } | |
| .txa-shortcuts-table td:last-child { border-radius: 0 10px 10px 0; border-left: none; text-align: right; } | |
| .txa-shortcuts-table kbd { | |
| background: #1e293b; color: #fff; padding: 3px 8px; border-radius: 6px; | |
| border-bottom: 2px solid #000; font-size: 10px; font-weight: 800; | |
| font-family: inherit; display: inline-block; min-width: 18px; text-align: center; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.5); | |
| } | |
| .txa-shortcuts-table tr:hover td { background: rgba(139, 92, 246, 0.1); border-color: rgba(139, 92, 246, 0.3); } | |
| .txa-stats-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:10px; padding-bottom:8px; border-bottom:1px solid rgba(255,255,255,0.08); } | |
| .txa-stats-title { font-size:12px; font-weight:800; color:var(--txa-brand); text-transform:uppercase; letter-spacing:1px; display:flex; align-items:center; gap:6px; } | |
| .txa-stats-title i { font-size:11px; } | |
| .txa-stats-close { background:none; border:none; color:rgba(255,255,255,0.4); cursor:pointer; font-size:14px; padding:4px; border-radius:6px; transition:0.2s; } | |
| .txa-stats-close:hover { color:#fff; background:rgba(255,255,255,0.1); } | |
| .txa-stats-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; } | |
| .txa-stat-item { display:flex; flex-direction:column; gap:2px; padding:6px 8px; background:rgba(255,255,255,0.03); border-radius:8px; } | |
| .txa-stat-item.full { grid-column:1/-1; } | |
| .txa-stat-label { font-size:9px; font-weight:700; color:rgba(255,255,255,0.35); text-transform:uppercase; letter-spacing:0.8px; } | |
| .txa-stat-value { font-size:13px; font-weight:700; color:#fff; font-variant-numeric:tabular-nums; } | |
| .txa-stat-value.brand { color:var(--txa-brand); } | |
| .txa-stat-value.green { color:#34d399; } | |
| .txa-stat-value.yellow { color:#fbbf24; } | |
| .txa-stat-value.red { color:#f87171; } | |
| @media (max-width:600px) { | |
| .txa-stats-panel { | |
| top:5px; left:5px; right:5px; width: auto; | |
| min-width:unset; max-width:unset; padding:12px; | |
| font-size:10px; border-radius: 12px; | |
| } | |
| .txa-stats-grid { grid-template-columns: 1fr; gap:4px; } | |
| .txa-stat-item { padding: 4px 8px; flex-direction: row; justify-content: space-between; align-items: center; } | |
| .txa-stat-item.full { flex-direction: column; align-items: flex-start; } | |
| .txa-stat-value { font-size:11px; text-align: right; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .txa-stat-item.full .txa-stat-value { text-align: left; max-width: 100%; } | |
| .txa-load-pct { font-size:22px; } | |
| .txa-load-speed { font-size:11px; } | |
| } | |
| /* REPLAY OVERLAY */ | |
| .txa-replay-overlay { | |
| position:absolute; inset:0; background:rgba(0,0,0,0.85); z-index:250; | |
| display:none; align-items:center; justify-content:center; flex-direction:column; gap:20px; | |
| backdrop-filter:blur(15px); | |
| } | |
| .txa-replay-overlay.show { display:flex; } | |
| .txa-replay-btn { | |
| width:100px; height:100px; background:var(--txa-brand); border:none; border-radius:50%; | |
| display:flex; align-items:center; justify-content:center; cursor:pointer; | |
| transition:all 0.3s; box-shadow:0 10px 40px var(--txa-brand-glow); | |
| } | |
| .txa-replay-btn:hover { transform:scale(1.1); box-shadow:0 15px 50px var(--txa-brand-glow); } | |
| .txa-replay-btn i { font-size:40px; color:#fff; } | |
| .txa-replay-text { font-size:18px; font-weight:600; color:#fff; } | |
| /* FEEDBACK POPUP */ | |
| .txa-feedback { | |
| position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) scale(0.8); | |
| background:var(--txa-glass); backdrop-filter:blur(20px); border:1px solid var(--txa-border); | |
| padding:16px 28px; border-radius:16px; font-size:22px; font-weight:700; color:#fff; | |
| opacity:0; pointer-events:none; transition:all 0.4s cubic-bezier(0.16,1,0.3,1); z-index:1100; | |
| box-shadow:0 15px 40px rgba(0,0,0,0.5); display:flex; align-items:center; gap:12px; | |
| } | |
| .txa-feedback.show { opacity:1; transform:translate(-50%,-50%) scale(1); } | |
| /* Smart Avoidance: Shift feedback up when Countdown is showing */ | |
| .has-countdown .txa-feedback.show { transform:translate(-50%, -120%) scale(1); } | |
| @media (max-width: 768px) { | |
| .has-countdown .txa-feedback.show { transform:translate(-50%, -150%) scale(0.9); } | |
| } | |
| .txa-feedback.high-priority { z-index: 1000; background: var(--txa-brand); border-color: rgba(255,255,255,0.3); box-shadow: 0 20px 50px var(--txa-brand-glow); } | |
| .txa-feedback.high-priority span { font-size: 18px; font-weight: 800; } | |
| .txa-feedback i { font-size:26px; color:var(--txa-brand); } | |
| .txa-feedback.high-priority i { color: #fff; } | |
| /* SEEK BADGES */ | |
| .txa-seek-badge { | |
| position:absolute; top:50%; width:90px; height:90px; background:rgba(0,0,0,0.6); | |
| border-radius:50%; display:flex; flex-direction:column; align-items:center; justify-content:center; | |
| backdrop-filter:blur(10px); border:1px solid var(--txa-border); | |
| opacity:0; pointer-events:none; transition:all 0.25s; z-index:100; | |
| } | |
| .txa-seek-badge.left { left:18%; transform:translate(-50%,-50%); } | |
| .txa-seek-badge.right { right:18%; transform:translate(50%,-50%); } | |
| .txa-seek-badge.show { opacity:1; } | |
| .txa-seek-badge i { font-size:22px; margin-bottom:6px; } | |
| .txa-seek-badge span { font-size:16px; font-weight:700; } | |
| /* CENTER PLAY */ | |
| .txa-center-play { | |
| position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) scale(0.9); | |
| width:80px; height:80px; background:rgba(255,255,255,0.15); border-radius:50%; | |
| display:flex; align-items:center; justify-content:center; backdrop-filter:blur(10px); | |
| border:1px solid rgba(255,255,255,0.2); opacity:0; transition:all 0.3s; pointer-events:none; | |
| z-index: 150; cursor: pointer; | |
| } | |
| .txa-center-play.show { opacity:1; transform:translate(-50%,-50%) scale(1); pointer-events:auto; } | |
| .txa-center-play i { font-size:30px; } | |
| .txa-video { width:100%; height:100%; object-fit:contain; cursor: pointer; } | |
| /* TICKER NOTIFICATION - PREMIUM MARQUEE */ | |
| .txa-ticker-container { | |
| position: absolute; bottom: 40px; left: 0; right: 0; pointer-events: none; z-index: 20000; display: none; | |
| height: 50px; overflow: hidden; | |
| mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent); | |
| -webkit-mask-image: linear-gradient(to right, transparent, black 15%, black 85%, transparent); | |
| transition: bottom 0.5s cubic-bezier(0.16, 1, 0.3, 1); | |
| } | |
| .txa-ticker-container.active { display: block; } | |
| .active-ui .txa-ticker-container { bottom: 130px; } /* Push up when controls are visible */ | |
| .has-countdown .txa-ticker-container { bottom: 180px; } | |
| .txa-ticker-text { | |
| position: absolute; top: 50%; transform: translateY(-50%); | |
| white-space: nowrap; font-size: 15px; font-weight: 500; color: rgba(255,255,255,0.9); | |
| text-shadow: 0 2px 10px rgba(0,0,0,0.8); | |
| font-family: 'Inter', sans-serif; letter-spacing: 0.3px; | |
| left: 100%; will-change: transform, left; | |
| padding: 10px 24px; border-radius: 12px; | |
| display: flex; align-items: center; gap: 10px; | |
| box-shadow: 0 10px 30px rgba(0,0,0,0.3); | |
| } | |
| .txa-ticker-text img { height: 20px; vertical-align: middle; margin-right: 5px; } | |
| /* Ticker Variants - Random Backgrounds */ | |
| .txa-ticker-text.style-1 { | |
| background: rgba(15, 23, 42, 0.8); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| } | |
| .txa-ticker-text.style-2 { | |
| background: linear-gradient(90deg, rgba(88, 28, 135, 0.85), rgba(124, 58, 237, 0.85)); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid rgba(139, 92, 246, 0.3); | |
| box-shadow: 0 10px 40px rgba(139, 92, 246, 0.4); | |
| } | |
| .txa-ticker-text.style-3 { | |
| background: linear-gradient(90deg, rgba(6, 182, 212, 0.85), rgba(59, 130, 246, 0.85)); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid rgba(6, 182, 212, 0.3); | |
| box-shadow: 0 10px 40px rgba(6, 182, 212, 0.4); | |
| } | |
| .txa-ticker-text.style-4 { | |
| background: rgba(255, 255, 255, 0.1); | |
| backdrop-filter: blur(20px) saturate(180%); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| color: #fff; | |
| } | |
| .txa-ticker-text span.highlight { color: #facc15; font-weight: 800; } | |
| .txa-ticker-text span.brand { color: #a78bfa; font-weight: 900; text-transform:uppercase; letter-spacing:1px; } | |
| .txa-ticker-text i { font-size: 18px; margin-right: 4px; } | |
| .txa-ticker-text.run { animation: txamarquee var(--ticker-dur) linear forwards; } | |
| @keyframes txamarquee { | |
| from { left: 100%; transform: translate(0, -50%); } | |
| to { left: 0%; transform: translate(-100%, -50%); } | |
| } | |
| /* TXA PRO TOGGLES */ | |
| /* TXA PRO TOGGLES - BADGE STYLE */ | |
| .txa-btn-pro { width: 42px; padding: 0; border-radius: 50%; gap: 0; border: 1px solid transparent; position: relative; } | |
| .txa-btn-pro:hover { background: rgba(255,255,255,0.1); border-color: transparent; } | |
| .txa-btn-status { | |
| position: absolute; top: -2px; right: -2px; | |
| font-size: 8px; font-weight: 900; | |
| padding: 1px 3px; border-radius: 6px; | |
| background: #475569; color: #cbd5e1; | |
| transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); | |
| min-width: 18px; text-align: center; | |
| border: 2px solid rgba(0,0,0,0.9); /* Cutout effect for dark mode */ | |
| transform: scale(0.9); z-index: 5; | |
| } | |
| .txa-btn-pro.active .txa-btn-status { | |
| background: var(--txa-brand); color: #fff; | |
| box-shadow: 0 0 8px var(--txa-brand-glow); | |
| transform: scale(1); | |
| border-color: #000; | |
| } | |
| .txa-btn-pro.active i { color: var(--txa-brand-light); filter: drop-shadow(0 0 5px var(--txa-brand-glow)); } | |
| /* CONTROLS */ | |
| .txa-controls { | |
| position:absolute; bottom:0; left:0; right:0; z-index:100; padding:20px; | |
| background:linear-gradient(transparent, rgba(0,0,0,0.95) 80%); pointer-events:none; | |
| opacity:0; transform:translateY(15px); transition:all 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| cursor: default; | |
| } | |
| .active-ui .txa-controls { opacity:1; transform:translateY(0); pointer-events:auto; } | |
| /* PROGRESS - THICKER */ | |
| .txa-progress-wrap { position:relative; height:10px; width:100%; cursor:pointer; margin-bottom:20px; transition:all 0.15s; } | |
| .txa-progress-wrap:hover { height:16px; } | |
| .txa-progress-bg { position:absolute; inset:0; background:rgba(255,255,255,0.15); border-radius:6px; overflow:hidden; } | |
| .txa-progress-buf { position:absolute; height:100%; background:rgba(255,255,255,0.4); border-radius:6px; width:0; transition: width 0.4s ease; overflow:hidden; z-index: 2; box-shadow: 0 0 10px rgba(255,255,255,0.1); } | |
| .txa-progress-buf.loading { background:rgba(255,255,255,0.5); } | |
| .txa-progress-buf.loading::after { content:''; position:absolute; inset:0; background:linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); animation:txaBufShimmer 1.5s ease-in-out infinite; } | |
| @keyframes txaBufShimmer { 0% { transform:translateX(-100%); } 100% { transform:translateX(100%); } } | |
| .txa-progress-fill { position:absolute; height:100%; background:var(--txa-brand); border-radius:6px; width:0; box-shadow:0 0 15px var(--txa-brand-glow); z-index: 3; } | |
| /* SCRUBBER - REFINED */ | |
| .txa-scrubber { | |
| position:absolute; right:-11px; top:50%; transform:translateY(-50%) scale(1); | |
| width:22px; height:22px; background:var(--txa-brand); border-radius:50%; | |
| box-shadow:0 0 15px var(--txa-brand-glow); transition:transform 0.15s; | |
| display:flex; align-items:center; justify-content:center; | |
| z-index: 5; border: 2px solid white; cursor: grab; | |
| } | |
| .txa-scrubber:active { cursor: grabbing; } | |
| .txa-scrubber::after { | |
| content:'T'; font-size:12px; font-weight:900; color:#fff; | |
| font-family:'Inter',sans-serif; | |
| } | |
| /* MARKERS - HIGHLIGHTED */ | |
| .txa-marker-zone { | |
| position:absolute; top:0; height:100%; z-index:5; pointer-events:none; | |
| } | |
| .txa-marker-zone.intro { | |
| background: #facc15; | |
| opacity: 0.8; | |
| box-shadow: 0 0 15px rgba(250, 204, 21, 0.6); | |
| } | |
| .txa-marker-zone.outro { | |
| background: #f87171; | |
| opacity: 0.8; | |
| box-shadow: 0 0 15px rgba(248, 113, 113, 0.6); | |
| } | |
| .txa-marker-dot { | |
| position:absolute; top:50%; width:8px; height:8px; border-radius:50%; | |
| transform:translateY(-50%); z-index:5; box-shadow:0 0 10px currentColor; border: 1px solid #fff; | |
| } | |
| .txa-marker-dot.intro-start, .txa-marker-dot.intro-end { background:var(--txa-intro); color:var(--txa-intro); } | |
| .txa-marker-dot.outro-start, .txa-marker-dot.outro-end { background:var(--txa-outro); color:var(--txa-outro); } | |
| /* HOVER TIME TOOLTIP */ | |
| .txa-hover-time { | |
| position:absolute; bottom:28px; transform:translateX(-50%); background:rgba(0,0,0,0.85); | |
| backdrop-filter: blur(8px); | |
| padding:4px 10px; border-radius:6px; font-size:13px; font-weight:800; pointer-events:none; | |
| color: #fff; | |
| opacity:0; transition:opacity 0.15s, bottom 0.2s ease; border:1px solid rgba(255,255,255,0.1); | |
| z-index:20; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.5); | |
| white-space: nowrap; | |
| } | |
| .txa-progress-wrap:hover .txa-hover-time { opacity:1; } | |
| .txa-progress-wrap.dragging .txa-hover-time { opacity:1; } | |
| /* Dynamic positioning when preview thumbnail is visible */ | |
| .txa-preview-thumb.has-sprite ~ .txa-hover-time { | |
| bottom: 142px; /* 40px (thumb bottom) + 90px (thumb height) + 12px (offset) */ | |
| background: var(--txa-brand); | |
| border-color: var(--txa-brand-glow); | |
| box-shadow: 0 4px 15px var(--txa-brand-glow); | |
| } | |
| /* PREVIEW THUMBNAIL on progress bar hover */ | |
| .txa-preview-thumb { | |
| position:absolute; bottom:40px; transform:translateX(-50%); | |
| width:160px; height:90px; | |
| background-color:#000; background-size:1600px 900px; background-repeat:no-repeat; | |
| border-radius:8px; border:2px solid rgba(139,92,246,0.6); | |
| box-shadow:0 8px 32px rgba(0,0,0,0.8), 0 0 16px rgba(139,92,246,0.3); | |
| pointer-events:none; opacity:0; transition:opacity 0.15s ease; | |
| z-index:11; overflow:hidden; | |
| } | |
| .txa-preview-thumb::after { | |
| content:''; position:absolute; inset:0; | |
| border-radius:6px; | |
| box-shadow: inset 0 0 0 1px rgba(255,255,255,0.1); | |
| pointer-events:none; | |
| } | |
| .txa-preview-thumb.loading::before { | |
| content:''; position:absolute; top:50%; left:50%; | |
| width:20px; height:20px; margin:-10px 0 0 -10px; | |
| border:2px solid rgba(255,255,255,0.15); border-top-color:var(--txa-brand); | |
| border-radius:50%; animation:txaspin 0.6s linear infinite; | |
| } | |
| .txa-progress-wrap:hover .txa-preview-thumb.has-sprite { opacity:1; } | |
| .txa-progress-wrap.dragging .txa-preview-thumb.has-sprite { opacity:1; } | |
| /* TOOLBAR */ | |
| .txa-toolbar { display:flex; align-items:center; justify-content:space-between; position:relative; z-index: 10; } | |
| .txa-group { display:flex; align-items:center; gap:6px; background:var(--txa-glass); backdrop-filter:blur(16px); padding:5px 10px; border-radius:100px; border:1px solid var(--txa-border); transition:all 0.3s; } | |
| .txa-btn { background:none; border:none; color:#fff; width:42px; height:42px; display:flex; align-items:center; justify-content:center; cursor:pointer; border-radius:50%; transition:all 0.2s; position:relative; } | |
| .txa-btn:hover { background:rgba(255,255,255,0.12); } | |
| .txa-btn i { font-size:17px; filter: drop-shadow(0 0 5px rgba(0,0,0,0.5)); } | |
| .txa-video { width:100%; height:100%; object-fit:contain; cursor: pointer; } | |
| .txa-wrapper { position:relative; width:100%; height:100%; outline:none; cursor: none; } | |
| .txa-wrapper.active-ui { cursor: auto; } | |
| /* v6.4.6 - Force Hide Controls (for Speed Preview) */ | |
| .txa-wrapper.hide-controls .txa-controls, | |
| .txa-wrapper.hide-controls .txa-top-info, | |
| .txa-wrapper.hide-controls .txa-realtime-clock { opacity: 0 !important; transform: translateY(15px); pointer-events: none !important; } | |
| .txa-wrapper.hide-controls .txa-top-info { transform: translateY(-20px); } | |
| /* CC/SUB INDICATOR */ | |
| .txa-btn.cc-active { color: var(--txa-brand); } | |
| .txa-btn.cc-active i { filter: drop-shadow(0 0 8px var(--txa-brand-glow)); } | |
| /* TOOLTIP PUSHED HIGHER - SMART POSITIONING */ | |
| /* TOOLTIP PUSHED HIGHER - SMART POSITIONING */ | |
| .txa-tooltip { position:absolute; bottom:70px; left:50%; transform:translateX(-50%); background:var(--txa-glass-light); padding:8px 16px; border-radius:10px; font-size:12px; font-weight:700; white-space:nowrap; opacity:0; transition:all 0.2s; pointer-events:none; border:1px solid var(--txa-border); z-index:1000; box-shadow: 0 10px 30px rgba(0,0,0,0.6); } | |
| /* Right align tooltips for right-side buttons */ | |
| .txa-right-group .txa-btn .txa-tooltip { left:auto; right:0; transform:translateX(0); } | |
| .txa-btn:hover .txa-tooltip { opacity:1; bottom:65px; } | |
| .txa-time { font-size:13px; font-weight:700; margin:0 14px; color:#cbd5e1; font-feature-settings:"tnum"; } | |
| /* VOLUME PREMIUM ENHANCED */ | |
| .txa-vol-container { display:flex; align-items:center; position:relative; } | |
| .txa-vol-slider-wrap { | |
| position:relative; | |
| width:0; overflow:hidden; transition:width 0.3s cubic-bezier(0.16, 1, 0.3, 1); | |
| display:flex; align-items:center; height: 42px; | |
| } | |
| .txa-vol-container:hover .txa-vol-slider-wrap { width:130px; } | |
| .txa-vol-slider { | |
| width:115px; height:24px; margin-left:12px; appearance:none; -webkit-appearance:none; | |
| background: linear-gradient(to right, var(--txa-brand) 0%, rgba(255,255,255,0.1) 0%); | |
| background-size: 100% 6px; | |
| background-position: center; | |
| background-repeat: no-repeat; | |
| border-radius:10px; cursor:pointer; outline:none; | |
| transition: background 0.2s; | |
| } | |
| .txa-vol-tooltip-p { | |
| position:absolute; bottom:75px; background:var(--txa-glass-light); | |
| backdrop-filter:blur(10px); color:#fff; padding:4px 10px; border-radius:8px; | |
| font-size:11px; font-weight:800; opacity:0; pointer-events:none; | |
| transition: opacity 0.2s, transform 0.2s; transform:translateX(-50%) translateY(5px); | |
| border:1px solid var(--txa-border); z-index:100; box-shadow:0 8px 20px rgba(0,0,0,0.4); | |
| } | |
| .txa-vol-tooltip-p.visible { opacity:1; transform:translateX(-50%) translateY(0); } | |
| .txa-vol-slider::-webkit-slider-thumb { | |
| -webkit-appearance:none; appearance:none; width:18px; height:18px; | |
| background:#fff; border-radius:50%; box-shadow:0 0 15px var(--txa-brand-glow); | |
| border: 3px solid var(--txa-brand); cursor:pointer; | |
| transition: transform 0.1s; | |
| } | |
| .txa-vol-slider::-webkit-slider-thumb:hover { transform: scale(1.2); } | |
| .txa-vol-slider::-moz-range-thumb { | |
| appearance:none; width:18px; height:18px; background:#fff; border-radius:50%; | |
| box-shadow:0 0 15px var(--txa-brand-glow); border: 3px solid var(--txa-brand); | |
| cursor:pointer; transition: transform 0.1s; | |
| } | |
| .txa-vol-slider::-moz-range-thumb:hover { transform: scale(1.2); } | |
| /* SETTINGS PANEL */ | |
| .txa-panel { | |
| position:absolute; bottom:95px; right:20px; width:300px; | |
| max-height: 480px; height: auto; overflow-y:auto; /* Fixed height safe */ | |
| background:var(--txa-glass); backdrop-filter:blur(30px); border:1px solid var(--txa-border); | |
| border-radius:20px; display:none; z-index:200; | |
| box-shadow:0 30px 60px rgba(0,0,0,0.7); animation:txafadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); | |
| } | |
| /* Custom Scrollbar for Panel */ | |
| .txa-panel::-webkit-scrollbar { width:4px; } | |
| .txa-panel::-webkit-scrollbar-track { background:transparent; } | |
| .txa-panel::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.2); border-radius:4px; } | |
| .txa-panel::-webkit-scrollbar-thumb:hover { background:var(--txa-brand); } | |
| .txa-panel.active { display:block; } | |
| @keyframes txafadeIn { from { opacity:0; transform:translateY(10px) scale(0.95); } to { opacity:1; transform:translateY(0) scale(1); } } | |
| .txa-panel-header { position: sticky; top: 0; background: #1a1a24; z-index: 20; padding:16px 20px; border-bottom:1px solid var(--txa-border); font-weight:700; font-size:15px; display:flex; align-items:center; gap:10px; } | |
| .txa-panel-header i { color:var(--txa-brand); } | |
| .txa-menu-item { padding:14px 20px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; font-size:14px; color:#e2e8f0; transition:all 0.2s; } | |
| .txa-menu-item:hover { background:rgba(255,255,255,0.08); } | |
| .txa-menu-item.active { color:var(--txa-brand); font-weight:700; background: rgba(139,92,246,0.1); } | |
| .txa-menu-item span:last-child { opacity:0.6; font-size:12px; font-weight:600; } | |
| .txa-menu-item.active span { opacity:1; } | |
| .txa-menu-back { position: sticky; top: 0; background: #1a1a24; z-index: 20; padding:14px 20px; border-bottom:1px solid var(--txa-border); display:flex; align-items:center; gap:12px; cursor:pointer; font-weight:700; font-size:14px; } | |
| .txa-menu-back:hover { background:rgba(255,255,255,0.08); } | |
| /* CONTEXT MENU */ | |
| .txa-ctx { | |
| position:absolute; min-width:240px; background:var(--txa-glass); backdrop-filter:blur(30px); | |
| border:1px solid var(--txa-border); border-radius:16px; padding:8px 0; display:none; z-index:300; | |
| box-shadow:0 20px 50px rgba(0,0,0,0.7); animation:txafadeIn 0.2s; | |
| } | |
| .txa-ctx.active { display:block; } | |
| .txa-ctx-header { padding:12px 18px; font-size:11px; text-transform:uppercase; letter-spacing:1px; color:var(--txa-brand); font-weight:800; border-bottom:1px solid var(--txa-border); margin-bottom:4px; } | |
| .txa-ctx-item { padding:12px 18px; font-size:13px; color:#e2e8f0; cursor:pointer; display:flex; justify-content:space-between; align-items:center; transition:0.15s; } | |
| .txa-ctx-item:hover { background:rgba(139,92,246,0.15); color:#fff; } | |
| .txa-ctx-divider { height:1px; background:var(--txa-border); margin:6px 0; } | |
| .txa-ctx-footer { padding:10px 18px; font-size:11px; color:#64748b; text-align:center; font-weight:600; } | |
| /* SWITCH TOGGLE */ | |
| .txa-switch { position:relative; display:inline-block; width:36px; height:20px; } | |
| .txa-switch input { opacity:0; width:0; height:0; } | |
| .txa-slider { position:absolute; cursor:pointer; inset:0; background:rgba(255,255,255,0.1); transition:.4s; border-radius:34px; border:1px solid var(--txa-border); } | |
| .txa-slider:before { position:absolute; content:""; height:14px; width:14px; left:2px; bottom:2px; background:white; transition:.4s; border-radius:50%; } | |
| .txa-switch input:checked + .txa-slider { background:var(--txa-brand); border-color:transparent; } | |
| .txa-switch input:checked + .txa-slider:before { transform:translateX(16px); } | |
| /* SUBTITLES - DRAGGABLE */ | |
| .txa-captions { | |
| position:absolute; bottom:110px; left:50%; transform:translateX(-50%); | |
| text-align:center; width:auto; max-width:80%; z-index:90; transition: bottom 0.3s; | |
| cursor:grab; user-select:none; white-space: pre-wrap; word-break: break-word; | |
| } | |
| .txa-captions.dragging { cursor:grabbing; } | |
| .txa-cue { | |
| display:inline-block; padding:8px 16px; border-radius:12px; | |
| background:rgba(0,0,0,0.75); font-size:24px; font-weight:600; | |
| text-shadow:0 2px 4px rgba(0,0,0,0.5); line-height: 1.4; | |
| pointer-events:none; | |
| } | |
| /* Subtitle Style Presets */ | |
| .txa-captions.size-mini .txa-cue { font-size:14px; padding:4px 8px; } | |
| .txa-captions.size-small .txa-cue { font-size:18px; padding:6px 12px; } | |
| .txa-captions.size-medium .txa-cue { font-size:24px; } | |
| .txa-captions.size-large .txa-cue { font-size:32px; padding:10px 20px; } | |
| .txa-captions.size-jumbo .txa-cue { font-size:48px; padding:14px 28px; } | |
| .txa-captions.bg-none .txa-cue { background:transparent; text-shadow:1px 1px 2px #000, -1px -1px 2px #000, 1px -1px 2px #000, -1px 1px 2px #000; } | |
| .txa-captions.bg-dark .txa-cue { background:rgba(0,0,0,0.85); } | |
| .txa-captions.bg-light .txa-cue { background:rgba(255,255,255,0.2); } | |
| .txa-captions.bg-glass .txa-cue { background:rgba(15, 23, 42, 0.6); backdrop-filter:blur(8px); border:1px solid rgba(255,255,255,0.1); } | |
| .txa-captions.weight-bold .txa-cue { font-weight: 800; } | |
| .txa-captions.weight-normal .txa-cue { font-weight: 500; } | |
| /* Subtitle Stroke (Border) */ | |
| .txa-captions.stroke-on .txa-cue { -webkit-text-stroke: 2px #000; paint-order: stroke fill; } | |
| .txa-captions.stroke-white .txa-cue { -webkit-text-stroke-color: #ffffff !important; } | |
| .txa-captions.stroke-black .txa-cue { -webkit-text-stroke-color: #000000 !important; } | |
| .txa-captions.stroke-yellow .txa-cue { -webkit-text-stroke-color: #facc15 !important; } | |
| .txa-captions.stroke-red .txa-cue { -webkit-text-stroke-color: #f87171 !important; } | |
| .txa-captions.stroke-blue .txa-cue { -webkit-text-stroke-color: #3b82f6 !important; } | |
| /* Subtitle Color Classes */ | |
| .txa-captions.color-white .txa-cue { color: #ffffff !important; } | |
| .txa-captions.color-yellow .txa-cue { color: #facc15 !important; } | |
| .txa-captions.color-orange .txa-cue { color: #fb923c !important; } | |
| .txa-captions.color-red .txa-cue { color: #f87171 !important; } | |
| .txa-captions.color-green .txa-cue { color: #4ade80 !important; } | |
| .txa-captions.color-cyan .txa-cue { color: #22d3ee !important; } | |
| .txa-captions.color-pink .txa-cue { color: #f472b6 !important; } | |
| .txa-captions.color-violet .txa-cue { color: #a78bfa !important; } | |
| .txa-stroke-ctrl { overflow:hidden; transition: max-height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s; max-height:0; opacity:0; } | |
| .txa-stroke-ctrl.active { max-height: 100px; opacity:1; padding-top:10px; } | |
| .txa-color-btn[data-substroke].active { transform:scale(1.2); border:2px solid #fff; } | |
| /* SKIP BUTTONS - PUSHED HIGHER */ | |
| .txa-skip-btn { position:absolute; bottom:160px; right:30px; z-index:120; background:rgba(255,255,255,0.1); border:2px solid var(--txa-border); backdrop-filter:blur(15px); color:#fff; padding:12px 28px; border-radius:14px; font-size:13px; font-weight:800; cursor:pointer; display:none; transition:all 0.3s cubic-bezier(0.16, 1, 0.3, 1); box-shadow: 0 10px 30px rgba(0,0,0,0.5); } | |
| .txa-skip-btn:hover { background:var(--txa-brand); border-color:transparent; transform:scale(1.05) translateX(-5px); } | |
| .txa-skip-btn.intro { border-color:var(--txa-intro); color:#fff; } | |
| .txa-skip-btn.intro:hover { background:var(--txa-intro); box-shadow: 0 10px 30px rgba(251,191,36,0.5); } | |
| .txa-skip-btn.outro { border-color:var(--txa-outro); color:#fff; } | |
| .txa-skip-btn.outro:hover { background:var(--txa-outro); box-shadow: 0 10px 30px rgba(239,68,68,0.5); } | |
| /* ZONES */ | |
| .txa-zone { position:absolute; top:0; bottom:0; width:30%; z-index:30; cursor: pointer; } | |
| .txa-zone.left { left:0; } | |
| .txa-zone.right { right:0; } | |
| /* UTILS */ | |
| .txa-hidden { display:none !important; } | |
| /* Grid for Sub Style */ | |
| .txa-grid-opts { display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:0 20px 14px; } | |
| .txa-grid-item { | |
| background:rgba(255,255,255,0.05); border:1px solid transparent; border-radius:8px; | |
| padding:8px; font-size:12px; text-align:center; cursor:pointer; transition:all 0.2s; color:#cbd5e1; | |
| } | |
| .txa-grid-item:hover { background:rgba(255,255,255,0.1); } | |
| .txa-grid-item.active { background:rgba(139,92,246,0.15); border-color:var(--txa-brand); color:#fff; font-weight:700; } | |
| /* Color circles */ | |
| .txa-color-opts { display:flex; gap:10px; padding:0 20px 20px; justify-content:center; } | |
| .txa-color-btn { width:32px; height:32px; border-radius:50%; border:2px solid transparent; cursor:pointer; transition:all 0.2s; position:relative; } | |
| .txa-color-btn.active { border-color:white; transform:scale(1.1); box-shadow:0 0 10px var(--txa-brand-glow); } | |
| .txa-color-btn[data-subcolor="white"] { background:#fff; } | |
| .txa-color-btn[data-subcolor="yellow"] { background:#facc15; } | |
| .txa-color-btn[data-subcolor="orange"] { background:#fb923c; } | |
| .txa-color-btn[data-subcolor="red"] { background:#f87171; } | |
| .txa-color-btn[data-subcolor="green"] { background:#4ade80; } | |
| .txa-color-btn[data-subcolor="cyan"] { background:#22d3ee; } | |
| .txa-color-btn[data-subcolor="pink"] { background:#f472b6; } | |
| .txa-color-btn[data-subcolor="violet"] { background:#a78bfa; } | |
| /* v5.8.0 - THEATER MODE */ | |
| body.theater-mode-active { | |
| overflow: hidden !important; | |
| } | |
| body.theater-mode-active::after { | |
| content: ""; | |
| position: fixed; | |
| inset: 0; | |
| background: rgba(0, 0, 0, 0.95); | |
| z-index: 999998; | |
| backdrop-filter: blur(15px); | |
| animation: txaFadeIn 0.5s ease forwards; | |
| } | |
| @keyframes txaFadeIn { | |
| from { opacity: 0; } | |
| to { opacity: 1; } | |
| } | |
| .txa-wrapper.theater-mode { | |
| z-index: 999999 !important; | |
| position: fixed !important; | |
| top: 50% !important; | |
| left: 50% !important; | |
| transform: translate(-50%, -50%) !important; | |
| width: 95vw !important; | |
| max-width: 1600px !important; | |
| height: 53.43vw !important; /* Aspect ratio 16:9 */ | |
| max-height: 90vh !important; | |
| border-radius: 24px; | |
| box-shadow: 0 0 100px rgba(0, 0, 0, 0.8), 0 0 40px rgba(229, 9, 20, 0.3); | |
| transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1) !important; | |
| } | |
| /* body.theater-mode-active handled here */ | |
| /* v5.8.0 - CINEMA AMBIENT LIGHT */ | |
| .txa-ambient-canvas { | |
| position: absolute; | |
| top: -50px; | |
| left: -50px; | |
| right: -50px; | |
| bottom: -50px; | |
| pointer-events: none; | |
| z-index: -1; | |
| filter: blur(80px) saturate(2); | |
| opacity: 0; | |
| transition: opacity 0.8s ease; | |
| } | |
| .txa-wrapper.cinema-mode .txa-ambient-canvas { | |
| opacity: 0.6; | |
| } | |
| .txa-wrapper.cinema-mode { | |
| overflow: visible; | |
| --txa-intro: #fbbf24; | |
| --txa-outro: #8b5cf6; | |
| } | |
| .txa-switch { position: relative; display: inline-block; width: 34px; height: 18px; margin-left: auto; } | |
| .txa-switch input { opacity: 0; width: 0; height: 0; } | |
| .txa-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(255,255,255,0.1); transition: .4s; border-radius: 34px; border: 1px solid var(--txa-border); } | |
| .txa-slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 2px; bottom: 2px; background-color: white; transition: .4s; border-radius: 50%; } | |
| input:checked + .txa-slider { background-color: var(--txa-brand); } | |
| input:checked + .txa-slider:before { transform: translateX(16px); } | |
| .txa-player-skin.cinema-active { | |
| background: transparent !important; | |
| } | |
| /* v5.8.0 - SPEED PREVIEW INDICATOR */ | |
| .txa-speed-indicator { | |
| position: absolute; | |
| top: 30px; | |
| left: 50%; | |
| transform: translateX(-50%) translateY(-20px) scale(0.9); | |
| background: rgba(139, 92, 246, 0.9); | |
| backdrop-filter: blur(10px); | |
| padding: 12px 24px; | |
| border-radius: 100px; | |
| font-size: 16px; | |
| font-weight: 800; | |
| color: #fff; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| z-index: 2147483647; | |
| opacity: 0; | |
| pointer-events: none; | |
| transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5), 0 0 20px rgba(139, 92, 246, 0.4); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .txa-speed-indicator.show { | |
| opacity: 1; | |
| transform: translateY(0) scale(1); | |
| } | |
| .txa-speed-indicator i { | |
| animation: txapulse 0.5s infinite alternate; | |
| } | |
| @keyframes txapulse { | |
| from { transform: scale(1); } | |
| to { transform: scale(1.2); } | |
| } | |
| /* v5.8.0 - RESUME PLAYBACK OVERLAY */ | |
| .txa-resume-overlay { | |
| position: absolute; | |
| bottom: 120px; | |
| left: 50%; | |
| transform: translateX(-50%) translateY(20px); | |
| background: var(--txa-glass); | |
| backdrop-filter: blur(20px); | |
| border: 1px solid var(--txa-border); | |
| border-radius: 20px; | |
| padding: 16px 24px; | |
| display: flex; | |
| align-items: center; | |
| gap: 16px; | |
| z-index: 210; | |
| opacity: 0; | |
| visibility: hidden; | |
| pointer-events: none; | |
| transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6); | |
| } | |
| .txa-resume-overlay.show { | |
| opacity: 1; | |
| visibility: visible; | |
| pointer-events: auto; | |
| transform: translateX(-50%) translateY(0); | |
| } | |
| .txa-resume-text { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .txa-resume-text span:first-child { | |
| font-size: 12px; | |
| color: #94a3b8; | |
| font-weight: 600; | |
| } | |
| .txa-resume-text span:last-child { | |
| font-size: 16px; | |
| font-weight: 800; | |
| color: #fff; | |
| } | |
| .txa-resume-btns { | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .txa-settings-panel { | |
| width: 100vw; | |
| max-width: none; | |
| padding: 24px 20px 40px 20px; | |
| border-radius: 24px 24px 0 0; | |
| bottom: 0; | |
| left: 0; | |
| right: 0; | |
| top: auto; | |
| transform: translateY(105%); | |
| max-height: 80vh; | |
| background: rgba(15, 23, 42, 0.98); | |
| box-shadow: 0 -10px 40px rgba(0,0,0,0.5); | |
| border: none; | |
| border-top: 1px solid rgba(255,255,255,0.1); | |
| transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| } | |
| .txa-settings-panel.active { | |
| transform: translateY(0); | |
| } | |
| .txa-settings-panel::before { | |
| content: ''; | |
| position: absolute; | |
| top: 10px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 40px; | |
| height: 4px; | |
| background: rgba(255,255,255,0.2); | |
| border-radius: 10px; | |
| } | |
| .txa-resume-btn { | |
| padding: 10px 20px; | |
| border-radius: 12px; | |
| border: none; | |
| font-weight: 700; | |
| font-size: 13px; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| } | |
| .txa-resume-btn.primary { | |
| background: var(--txa-brand); | |
| color: #fff; | |
| box-shadow: 0 5px 20px var(--txa-brand-glow); | |
| } | |
| .txa-resume-btn.primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 8px 25px var(--txa-brand-glow); | |
| } | |
| .txa-resume-btn.secondary { | |
| background: rgba(255, 255, 255, 0.1); | |
| color: #fff; | |
| border: 1px solid var(--txa-border); | |
| } | |
| .txa-resume-btn.secondary:hover { | |
| background: rgba(255, 255, 255, 0.15); | |
| } | |
| .txa-ripple { | |
| position: absolute; | |
| width: 100px; | |
| height: 100px; | |
| background: radial-gradient(circle, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 70%); | |
| border-radius: 50%; | |
| transform: scale(0); | |
| pointer-events: none; | |
| z-index: 50; | |
| animation: txaripple 0.6s ease-out forwards; | |
| } | |
| @keyframes txaripple { | |
| 0% { transform: scale(0); opacity: 1; } | |
| 100% { transform: scale(4); opacity: 0; } | |
| } | |
| .txa-btn.mode-active { | |
| color: var(--txa-brand); | |
| } | |
| .txa-btn.mode-active i { | |
| filter: drop-shadow(0 0 8px var(--txa-brand-glow)); | |
| } | |
| .txa-badge-quality { | |
| position: absolute; | |
| top: 4px; | |
| right: 4px; | |
| background: rgba(255,255,255,0.2); | |
| color: #fff; | |
| font-size: 8px; | |
| font-weight: 800; | |
| padding: 1px 3px; | |
| border-radius: 3px; | |
| line-height: 1; | |
| pointer-events: none; | |
| transition: all 0.3s; | |
| display: none; | |
| z-index: 2; | |
| font-family: sans-serif; | |
| box-shadow: 0 1px 3px rgba(0,0,0,0.3); | |
| } | |
| /* --- 2X SPEED OVERLAY --- */ | |
| .txa-speed-overlay { | |
| position: absolute; | |
| top: 15%; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: rgba(0, 0, 0, 0.6); | |
| color: #fff; | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| font-size: 13px; | |
| font-weight: 700; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| pointer-events: none; | |
| z-index: 10000; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| backdrop-filter: blur(4px); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| } | |
| .txa-speed-overlay.show { opacity: 1; } | |
| /* v6.2.0 - TOP INFO BAR */ | |
| .txa-top-info { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| padding: 15px 25px; | |
| background: linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 50%, transparent 100%); | |
| z-index: 10002; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| opacity: 0; | |
| transform: translateY(-20px); | |
| transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); | |
| pointer-events: none; | |
| } | |
| .active-ui .txa-top-info { opacity: 1; transform: translateY(0); } | |
| .txa-top-title { | |
| font-weight: 700; | |
| font-size: 15px; | |
| color: #fff; | |
| text-shadow: 0 2px 10px rgba(0,0,0,0.5); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| max-width: 80%; | |
| } | |
| /* Desktop Title Size Increase */ | |
| @media (min-width: 1024px) { | |
| .txa-top-title { font-size: 20px; } | |
| .txa-top-title i { font-size: 18px; } | |
| .txa-top-logo { font-size: 24px; } | |
| } | |
| .txa-top-title i { color: #facc15; font-size: 14px; } | |
| .txa-top-logo { font-weight: 800; font-size: 18px; letter-spacing: 0.5px; } | |
| .txa-logo-t { color: #a855f7; } | |
| .txa-logo-phim { color: #fff; } | |
| .txa-logo-x { color: #ef4444; } | |
| /* Aspect Ratio & Flip Support */ | |
| .txa-video.ratio-4-3 { object-fit: fill; aspect-ratio: 4/3; width: auto; height: 100%; margin: 0 auto; display: block; } | |
| .txa-video.ratio-16-9 { object-fit: contain; aspect-ratio: 16/9; } | |
| .txa-video.ratio-stretch { object-fit: fill; } | |
| .txa-video.flip-h { transform: scaleX(-1); } | |
| .txa-video.flip-v { transform: scaleY(-1); } | |
| .txa-video.flip-both { transform: scale(-1); } | |
| /* Hide simple logo in FS when controls are shown */ | |
| .fullscreen-mode.active-ui .txa-brand-logo { opacity: 0 !important; visibility: hidden !important; } | |
| /* --- NEXT EPISODE COUNTDOWN OVERLAY --- */ | |
| .txa-next-countdown { | |
| position: absolute; bottom: 120px; right: 30px; | |
| background: rgba(15, 23, 42, 0.7); backdrop-filter: blur(25px); -webkit-backdrop-filter: blur(25px); | |
| border: 1px solid rgba(255,255,255,0.15); border-radius: 20px; padding: 20px; | |
| display: flex; align-items: center; gap: 20px; z-index: 220; | |
| transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1); | |
| opacity: 0; transform: translateY(20px) scale(0.9); pointer-events: none; | |
| box-shadow: 0 15px 45px rgba(0,0,0,0.5), 0 0 20px rgba(139,92,246,0.2); | |
| max-width: 400px; | |
| padding: 25px; | |
| } | |
| .txa-next-countdown.show { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; } | |
| .txa-nc-thumb-wrap { position: relative; width: 140px; height: 79px; flex-shrink: 0; } | |
| .txa-nc-thumb { width: 100%; height: 100%; border-radius: 12px; object-fit: cover; background: #000; border: 1px solid rgba(255,255,255,0.1); } | |
| .txa-nc-badge { | |
| position: absolute; top: -10px; left: -10px; | |
| background: var(--txa-brand); color: #fff; | |
| padding: 4px 10px; border-radius: 8px; | |
| font-family: 'Outfit', sans-serif; font-weight: 900; font-size: 16px; | |
| box-shadow: 0 4px 15px var(--txa-brand-glow); | |
| border: 2px solid rgba(255,255,255,0.2); | |
| transform: rotate(-5deg); | |
| z-index: 2; | |
| text-shadow: 0 2px 4px rgba(0,0,0,0.3); | |
| } | |
| .txa-nc-badge span { font-size: 10px; text-transform: uppercase; margin-right: 3px; opacity: 0.8; font-weight: 600; } | |
| .txa-nc-info { flex: 1; display: flex; flex-direction: column; gap: 6px; } | |
| .txa-nc-label { font-size: 12px; font-weight: 800; color: #94a3b8; text-transform: uppercase; letter-spacing: 1.2px; } | |
| .txa-nc-title { font-size: 16px; font-weight: 700; color: #fff; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } | |
| .txa-nc-timer { font-size: 13px; font-weight: 600; color: var(--txa-brand-light); } | |
| .txa-nc-actions { display: flex; flex-direction: column; gap: 10px; } | |
| .txa-nc-next { | |
| background: var(--txa-brand); border: none; color: #fff; font-size: 12px; font-weight: 800; | |
| padding: 8px 16px; border-radius: 10px; cursor: pointer; transition: 0.3s; | |
| text-align: center; box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3); | |
| } | |
| .txa-nc-next:hover { transform: translateY(-2px); filter: brightness(1.1); } | |
| .txa-nc-cancel { | |
| background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1); color: #fff; font-size: 11px; font-weight: 700; | |
| padding: 6px 12px; border-radius: 10px; cursor: pointer; transition: 0.3s; | |
| opacity: 0.7; | |
| } | |
| .txa-nc-cancel:hover { background: rgba(255,255,255,0.15); opacity: 1; } | |
| .txa-desktop-only { display: none; } | |
| @media (min-width: 1024px) { .txa-desktop-only { display: block; } } | |
| @media (max-width: 768px) { | |
| .txa-next-countdown { left: 15px; right: 15px; bottom: 85px; max-width: none; padding: 15px; gap: 12px; } | |
| .txa-nc-thumb-wrap { width: 100px; height: 56px; } | |
| .txa-nc-badge { font-size: 13px; padding: 3px 8px; top: -8px; left: -8px; } | |
| /* Mobile Controls Minimalist */ | |
| .txa-controls { padding: 8px 15px; } | |
| .txa-toolbar { display: flex; align-items: center; justify-content: space-between; } | |
| .txa-group { padding: 3px 6px; gap: 4px; background: rgba(0,0,0,0.4); } | |
| .txa-btn { width: 34px; height: 34px; } | |
| .txa-btn i { font-size: 14px; } | |
| .txa-time { font-size: 10px; margin: 0 4px; } | |
| /* Hide secondary tools on mobile vertical */ | |
| #txa-theater, #txa-cinema, #txa-pip, .txa-vol-container, .txa-watermark { display: none; } | |
| /* Keep PRO buttons small & showing status */ | |
| .txa-btn-pro { width: 32px; height: 32px; } | |
| .txa-btn-status { font-size: 7px; min-width: 14px; top: -1px; right: -1px; border-width: 1px; transform: scale(0.85); visibility: visible !important; opacity: 1 !important; } | |
| /* Mobile Controls Minimalist */ | |
| .txa-progress-wrap { height: 14px; margin-bottom: 25px; } | |
| .txa-progress-wrap::before { | |
| content: ''; position: absolute; top: -20px; bottom: -20px; left: 0; right: 0; | |
| } | |
| /* Fullscreen Landscape Adjustments */ | |
| .fullscreen-mode .txa-controls { padding: 15px 40px 30px; } | |
| .fullscreen-mode .txa-toolbar { gap: 15px; } | |
| /* AUTOMATIC MINIMALISM BASED ON WIDTH */ | |
| @container player (max-width: 500px) { | |
| .txa-time { display: none !important; } | |
| #txa-cc, #txa-pip, #txa-auto-skip, #txa-auto-next { display: none !important; } | |
| } | |
| /* Ticker - Only show in fullscreen on mobile as requested */ | |
| .txa-ticker-container { display: none !important; } | |
| .fullscreen-mode .txa-ticker-container.active { display: block !important; bottom: 60px; } | |
| .fullscreen-mode.active-ui .txa-ticker-container { bottom: 120px; } | |
| /* EXTREME MINIMALISM (< 480px) */ | |
| @media (max-width: 480px) { | |
| #txa-auto-skip, #txa-auto-next, #txa-cc, .txa-time { display: none !important; } | |
| .txa-group { background: transparent; padding: 0; } | |
| .txa-controls { padding: 5px 10px; } | |
| .txa-toolbar { height: 40px; } | |
| .txa-btn { width: 42px; height: 42px; } | |
| .txa-btn i { font-size: 18px; } | |
| .txa-top-info { padding: 10px 15px; } | |
| .txa-top-logo { display: none; } | |
| } | |
| /* TABLET ADAPTATION (768px - 1024px) */ | |
| @media (min-width: 769px) and (max-width: 1024px) { | |
| .txa-toolbar { gap: 5px; } | |
| #txa-theater, #txa-cinema { display: none; } | |
| .txa-btn-status { font-size: 8px; padding: 1px 3px; } | |
| } | |
| } | |
| .txa-ticker-text { font-size: 13px; padding: 8px 18px; } | |
| /* v6.4.0 - Intermediate Responsive (Tablet/Resized Desktop) */ | |
| @media (max-width: 1024px) { | |
| .txa-vol-container:hover .txa-vol-slider-wrap { width: 100px; } | |
| .txa-vol-slider { width: 85px; } | |
| .txa-top-title { font-size: 16px; } | |
| } | |
| /* Hide some buttons on very narrow players even on desk */ | |
| @media (max-width: 650px) { | |
| #txa-theater, #txa-cinema, #txa-pip, .txa-vol-container { display: none !important; } | |
| .txa-group { padding: 4px 8px; } | |
| .txa-btn { width: 38px; height: 38px; } | |
| } | |
| /* Ultra small devices */ | |
| @media (max-width: 375px) { | |
| .txa-time, #txa-prev, #txa-next { display: none !important; } | |
| .txa-nc-title { font-size: 14px; } | |
| .txa-top-info { padding: 8px 12px; } | |
| .txa-top-logo { font-size: 16px; } | |
| .txa-brand-logo { display: none !important; } | |
| .txa-realtime-clock { display: none !important; } | |
| } | |
| /* Mobile Landscape Enhancements */ | |
| @media (max-width: 768px) and (orientation: landscape) { | |
| .txa-controls { padding: 6px 12px; } | |
| .txa-toolbar { height: 35px; } | |
| .txa-btn { width: 32px; height: 32px; } | |
| .txa-btn i { font-size: 12px; } | |
| .txa-time { font-size: 9px; margin: 0 2px; } | |
| .txa-progress-wrap { height: 12px; margin-bottom: 20px; } | |
| } | |
| /* Touch Device Optimizations */ | |
| @media (hover: none) and (pointer: coarse) { | |
| .txa-btn:hover { background: none; } | |
| .txa-btn:active { background: rgba(255,255,255,0.15); transform: scale(0.95); } | |
| .txa-tooltip { display: none !important; } | |
| /* Larger touch targets for mobile */ | |
| .txa-btn { min-width: 44px; min-height: 44px; } | |
| .txa-vol-slider { height: 28px; } | |
| .txa-vol-slider::-webkit-slider-thumb { width: 20px; height: 20px; } | |
| .txa-vol-slider::-moz-range-thumb { width: 20px; height: 20px; } | |
| } | |
| /* High DPI Mobile Displays */ | |
| @media (max-width: 768px) and (-webkit-min-device-pixel-ratio: 2) { | |
| .txa-btn i { filter: drop-shadow(0 0 3px rgba(0,0,0,0.3)); } | |
| .txa-progress-wrap { height: 16px; } | |
| .txa-scrubber { width: 16px; height: 16px; } | |
| } | |
| .txa-menu-item.disabled { opacity: 0.5; pointer-events: auto; cursor: not-allowed; } | |
| .txa-menu-item.disabled:hover { background: transparent; } | |
| /* v6.6.2 - MOBILE OPTIMIZATIONS (NEON CORE) */ | |
| .is-mobile #txa-wrapper { border-radius: 0 !important; margin: 0 !important; box-shadow: none !important; } | |
| .is-mobile .txa-btn { width: 48px; height: 48px; font-size: 18px; background: rgba(255,255,255,0.03); border-radius: 12px; } | |
| .is-mobile .txa-progress-container { padding: 15px 0; } | |
| .is-mobile .txa-progress-wrapper { height: 4px; } | |
| .is-mobile .txa-controls { padding: 0 15px 15px; height: 80px; } | |
| .is-mobile .txa-time { font-size: 13px; font-weight: 700; color: rgba(255,255,255,0.8); } | |
| .is-mobile .txa-skip-btn { bottom: 100px; right: 15px; padding: 10px 20px; font-size: 12px; font-weight: 800; border-radius: 14px; } | |
| .is-mobile .txa-preview-thumb { width: 140px; height: 78px; bottom: 90px; } | |
| .is-mobile .txa-volume-container { display: none !important; } | |
| .is-mobile .txa-top-info { padding: 15px; background: linear-gradient(to bottom, rgba(0,0,0,0.9), transparent); } | |
| .is-mobile .txa-top-title { font-size: 14px; max-width: 70%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| .is-mobile .txa-top-logo { display: none; } | |
| .is-mobile .txa-panel { width: 100% !important; border-radius: 24px 24px 0 0 !important; bottom: 0 !important; right: 0 !important; top: auto !important; transform: translateY(100%); max-height: 80vh; background: #0a0f1d !important; } | |
| .is-mobile .txa-panel.active { transform: translateY(0); } | |
| .is-mobile .txa-panel-header { padding: 24px; font-size: 16px; } | |
| .is-mobile .txa-menu-item { padding: 18px 24px; font-size: 15px; } | |
| .is-mobile .txa-zone-hint { width: 70px; height: 70px; } | |
| .is-mobile #txa-theater, .is-mobile #txa-cinema { display: none !important; } | |
| .is-mobile .txa-next-countdown { width: calc(100% - 30px); bottom: 100px; left: 15px; padding: 15px; border-radius: 20px; font-size: 14px; } | |
| .is-mobile .txa-nc-btn { padding: 8px 16px; font-size: 12px; } | |
| @keyframes txa-mobile-fade { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } | |
| `; | |
| document.head.appendChild(s); | |
| } | |
| renderUI() { | |
| this.container.innerHTML = ` | |
| <div class="txa-wrapper active-ui" id="txa-wrapper" tabindex="0"> | |
| <!-- TOP INFO BAR --> | |
| <div class="txa-top-info" id="txa-top-info"> | |
| <div class="txa-top-title" id="txa-top-title"> | |
| <i class="fas fa-play-circle"></i> | |
| <span>TPhimX Media</span> | |
| </div> | |
| <div class="txa-top-logo"> | |
| <span class="txa-logo-t">T</span><span class="txa-logo-phim">PHIM</span><span class="txa-logo-x">X</span> | |
| </div> | |
| </div> | |
| <!-- BRANDING --> | |
| <div class="txa-brand-logo"> | |
| <div class="txa-logo-text"> | |
| <span class="txa-logo-t">T</span><span class="txa-logo-phimx">PHIMX</span> | |
| </div> | |
| </div> | |
| <!-- REAL-TIME CLOCK v6.4.0 --> | |
| <div id="txa-clock" class="txa-realtime-clock"> | |
| <i class="fas fa-clock"></i> | |
| <span id="txa-clock-text">00:00:00</span> | |
| </div> | |
| <div class="txa-watermark"><span>TPHIMX</span> Player-${TXA_VERSION}</div> | |
| <video class="txa-video" id="txa-video" playsinline poster="${this.options.posterUrl || ''}"></video> | |
| <div id="txa-loader" class="txa-loader"> | |
| <div class="txa-loader-content"> | |
| <div class="txa-spinner"></div> | |
| <div class="txa-load-pct-row"> | |
| <div class="txa-load-pct" id="txa-load-pct">0%</div> | |
| <div class="txa-load-speed" id="txa-load-speed">-- KB/s</div> | |
| </div> | |
| <div class="txa-load-bar"><div class="txa-load-bar-fill" id="txa-load-bar-fill"></div></div> | |
| <div class="txa-load-text" id="txa-load-text">Vui lòng chờ trong giây lát...</div> | |
| <div class="txa-load-info" id="txa-load-info"></div> | |
| </div> | |
| </div> | |
| <!-- STATS PANEL --> | |
| <div class="txa-stats-panel" id="txa-stats-panel"> | |
| <div class="txa-stats-header"> | |
| <div class="txa-stats-title"><i class="fas fa-chart-bar"></i> STREAM STATS</div> | |
| <button class="txa-stats-close" id="txa-stats-close"><i class="fas fa-times"></i></button> | |
| </div> | |
| <div class="txa-stats-grid" id="txa-stats-grid"></div> | |
| </div> | |
| <div id="txa-replay" class="txa-replay-overlay"> | |
| <button class="txa-replay-btn" id="txa-replay-btn"><i class="fas fa-redo"></i></button> | |
| <span class="txa-replay-text">Phát lại</span> | |
| </div> | |
| <div id="txa-feedback" class="txa-feedback"><i class="fas fa-volume-up"></i><span>50%</span></div> | |
| <div id="txa-badge-l" class="txa-seek-badge left"><i class="fas fa-angle-double-left"></i><span>-10s</span></div> | |
| <div id="txa-badge-r" class="txa-seek-badge right"><i class="fas fa-angle-double-right"></i><span>+10s</span></div> | |
| <div id="txa-center-play" class="txa-center-play"><i class="fas fa-play"></i></div> | |
| <div id="txa-captions" class="txa-captions"></div> | |
| <button id="txa-skip-intro" class="txa-skip-btn intro"><i class="fas fa-forward"></i> SKIP INTRO</button> | |
| <button id="txa-skip-outro" class="txa-skip-btn outro" style="bottom:230px;"><i class="fas fa-step-forward"></i> SKIP OUTRO</button> | |
| <canvas id="txa-ambient" class="txa-ambient-canvas"></canvas> | |
| <div id="txa-speed-indicator" class="txa-speed-indicator"> | |
| <i class="fas fa-forward"></i> | |
| <span>2x Tua nhanh</span> | |
| </div> | |
| <div id="txa-resume" class="txa-resume-overlay"> | |
| <div class="txa-resume-text"> | |
| <span>Tiếp tục xem?</span> | |
| <span id="txa-resume-time">00:00</span> | |
| </div> | |
| <div class="txa-resume-btns"> | |
| <button class="txa-resume-btn primary" id="txa-resume-yes"> | |
| <i class="fas fa-play" style="margin-right:6px;"></i>Tiếp tục | |
| </button> | |
| <button class="txa-resume-btn secondary" id="txa-resume-no">Xem từ đầu</button> | |
| </div> | |
| </div> | |
| <!-- NEXT COUNTDOWN --> | |
| <div id="txa-next-countdown" class="txa-next-countdown"> | |
| <div class="txa-nc-thumb-wrap"> | |
| <div class="txa-nc-badge" id="txa-nc-badge"><span>Tập</span>--</div> | |
| <img src="${this.options.movieThumb || ''}" id="txa-nc-thumb" class="txa-nc-thumb"> | |
| </div> | |
| <div class="txa-nc-info"> | |
| <span class="txa-nc-label">Tập Tiếp Theo</span> | |
| <span class="txa-nc-title" id="txa-nc-title">Đang chuẩn bị...</span> | |
| <span class="txa-nc-timer" id="txa-nc-timer">Bắt đầu sau 05s</span> | |
| </div> | |
| <div class="txa-nc-actions"> | |
| <button class="txa-nc-next txa-desktop-only" id="txa-nc-next">Chuyển Ngay</button> | |
| <button class="txa-nc-cancel" id="txa-nc-cancel">Hủy</button> | |
| </div> | |
| </div> | |
| <!-- TICKER --> | |
| <div id="txa-ticker" class="txa-ticker-container"> | |
| <div id="txa-ticker-text" class="txa-ticker-text"></div> | |
| </div> | |
| <div class="txa-controls"> | |
| <div class="txa-progress-wrap" id="txa-progress"> | |
| <div class="txa-progress-bg"></div> | |
| <div class="txa-progress-buf" id="txa-buffer"></div> | |
| <div class="txa-progress-fill" id="txa-played"><div class="txa-scrubber"></div></div> | |
| <div id="txa-markers" style="position:absolute; inset:0; z-index:20; pointer-events:none;"></div> | |
| <div class="txa-preview-thumb" id="txa-preview-thumb"></div> | |
| <div class="txa-hover-time" id="txa-hover-time">0:00</div> | |
| </div> | |
| <div class="txa-toolbar"> | |
| <div style="display:flex;align-items:center;gap:10px;"> | |
| <div class="txa-group"> | |
| <button class="txa-btn txa-btn-nav" id="txa-prev" style="${!this.options.prevEpisodeUrl ? 'display:none;' : ''}" data-txatooltip="Tập trước"><i class="fas fa-step-backward"></i></button> | |
| <button class="txa-btn" id="txa-play" data-txatooltip="Play (k)"><i class="fas fa-play"></i></button> | |
| <button class="txa-btn txa-btn-nav" id="txa-next" style="${!this.options.nextEpisodeUrl ? 'display:none;' : ''}" data-txatooltip="Tập sau"><i class="fas fa-step-forward"></i></button> | |
| <div class="txa-vol-container"> | |
| <button class="txa-btn" id="txa-mute" data-txatooltip="Mute (m)"><i class="fas fa-volume-up"></i></button> | |
| <div class="txa-vol-tooltip-p" id="txa-vol-tooltip">100%</div> | |
| <div class="txa-vol-slider-wrap"> | |
| <input type="range" class="txa-vol-slider" id="txa-vol" min="0" max="1" step="0.02" value="1"> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="txa-time" id="txa-time">0:00 / 0:00</div> | |
| </div> | |
| <div style="display:flex;align-items:center;gap:10px;"> | |
| <div class="txa-group txa-right-group"> | |
| <button class="txa-btn txa-btn-pro" id="txa-auto-skip" data-txatooltip="Tự động bỏ qua giới thiệu (Pro)" style="${!this.options.isLoggedIn ? 'display:none;' : ''}"> | |
| <i class="fas fa-forward"></i><span class="txa-btn-status">OFF</span> | |
| </button> | |
| <button class="txa-btn txa-btn-pro" id="txa-auto-next" data-txatooltip="Tự động chuyển tập (Pro)" style="${!this.options.isLoggedIn ? 'display:none;' : ''}"> | |
| <i class="fas fa-step-forward"></i><span class="txa-btn-status">OFF</span> | |
| </button> | |
| <div style="width:1px;height:20px;background:rgba(255,255,255,0.1);margin:0 4px;"></div> | |
| <button class="txa-btn" id="txa-cc" style="${!this.options.hasSubtitles ? 'display:none;' : ''}" data-txatooltip="Phụ đề (c)"><i class="far fa-closed-captioning"></i></button> | |
| <button class="txa-btn" id="txa-cinema" data-txatooltip="Ánh sáng (a)"><i class="fas fa-lightbulb"></i></button> | |
| <button class="txa-btn" id="txa-theater" data-txatooltip="Rạp phim (t)"><i class="fas fa-tv"></i></button> | |
| <button class="txa-btn" id="txa-settings" data-txatooltip="Cài đặt"> | |
| <i class="fas fa-cog"></i> | |
| <span id="txa-quality-badge" class="txa-badge-quality"></span> | |
| </button> | |
| <button class="txa-btn" id="txa-pip" data-txatooltip="Mini Player (p)"><i class="fas fa-clone"></i></button> | |
| <button class="txa-btn" id="txa-fs" data-txatooltip="Toàn màn hình (f)"><i class="fas fa-expand"></i></button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="txa-panel" id="txa-panel"> | |
| <div id="txa-view-main"> | |
| <div class="txa-panel-header"><i class="fas fa-sliders-h"></i> Settings</div> | |
| <div class="txa-menu-item" data-goto="speed"><span>Playback Speed</span><span id="txa-lbl-speed">1x</span></div> | |
| <div class="txa-menu-item" data-goto="ratio"><span>Aspect Ratio</span><span id="txa-lbl-ratio">Default</span></div> | |
| <div class="txa-menu-item" data-goto="flip"><span>Video Flip</span><span id="txa-lbl-flip">Normal</span></div> | |
| <div class="txa-menu-item" data-goto="subs" id="txa-item-subs" style="${!this.options.hasSubtitles ? 'display:none;' : ''}"><span>Subtitles</span><span id="txa-lbl-subs">Off</span></div> | |
| <div class="txa-menu-item" data-goto="quality" id="txa-item-quality" style="display:none;"><span>Quality</span><span id="txa-lbl-quality">Auto</span></div> | |
| <div class="txa-menu-item" style="border-top:1px solid var(--txa-border);margin-top:8px;padding-top:10px;justify-content:space-between;"> | |
| <span style="display:flex;align-items:center;gap:10px;"><i class="fas fa-clock" style="opacity:0.7;"></i> Hiện đồng hồ</span> | |
| <label class="txa-switch"><input type="checkbox" id="txa-clock-toggle"><span class="txa-slider"></span></label> | |
| </div> | |
| <div class="txa-menu-item" data-goto="clock" id="txa-item-clock" style="display: none;"><span>Định dạng giờ</span><span id="txa-lbl-clock">H:i:s</span></div> | |
| </div> | |
| <div id="txa-view-clock" class="txa-hidden"> | |
| <div class="txa-menu-back" data-back="main"><i class="fas fa-chevron-left"></i> Kiểu hiển thị đồng hồ</div> | |
| <div style="padding:14px 20px 8px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;">Chỉ thời gian</div> | |
| <div class="txa-menu-item active" data-clock="H:i:s"><span>Giờ : Phút : Giây</span><span style="font-size:10px;opacity:0.5;">H:i:s</span></div> | |
| <div class="txa-menu-item" data-clock="H:i"><span>Giờ : Phút</span><span style="font-size:10px;opacity:0.5;">H:i</span></div> | |
| <div style="padding:10px 20px 8px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;border-top:1px solid var(--txa-border);margin-top:4px;padding-top:14px;">Thời gian & Ngày</div> | |
| <div class="txa-menu-item" data-clock="H:i:s d/M/YYYY"><span>Đầy đủ</span><span style="font-size:10px;opacity:0.5;">H:i:s d/M/YYYY</span></div> | |
| <div class="txa-menu-item" data-clock="H:i d/M/YYYY"><span>Gọn</span><span style="font-size:10px;opacity:0.5;">H:i d/M/YYYY</span></div> | |
| <div class="txa-menu-item" data-clock="H:i:s d/M"><span>Giờ & Ngày/Tháng</span><span style="font-size:10px;opacity:0.5;">H:i:s d/M</span></div> | |
| <div style="padding:10px 20px 8px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;border-top:1px solid var(--txa-border);margin-top:4px;padding-top:14px;">Chỉ ngày tháng</div> | |
| <div class="txa-menu-item" data-clock="d/M/YYYY"><span>Ngày / Tháng / Năm</span><span style="font-size:10px;opacity:0.5;">d/M/Y</span></div> | |
| <div class="txa-menu-item" data-clock="H:i:s - YYYY/M/d"><span>Chuẩn ISO</span><span style="font-size:10px;opacity:0.5;">YYYY/M/d</span></div> | |
| </div> | |
| <div id="txa-view-ratio" class="txa-hidden"> | |
| <div class="txa-menu-back" data-back="main"><i class="fas fa-chevron-left"></i> Aspect Ratio</div> | |
| <div class="txa-menu-item active" data-ratio="default">Default</div> | |
| <div class="txa-menu-item" data-ratio="4-3">4:3</div> | |
| <div class="txa-menu-item" data-ratio="16-9">16:9</div> | |
| <div class="txa-menu-item" data-ratio="stretch">Stretch</div> | |
| </div> | |
| <div id="txa-view-flip" class="txa-hidden"> | |
| <div class="txa-menu-back" data-back="main"><i class="fas fa-chevron-left"></i> Video Flip</div> | |
| <div class="txa-menu-item active" data-flip="normal">Normal</div> | |
| <div class="txa-menu-item" data-flip="h">Horizontal</div> | |
| <div class="txa-menu-item" data-flip="v">Vertical</div> | |
| <div class="txa-menu-item" data-flip="both">Both</div> | |
| </div> | |
| <div id="txa-view-speed" class="txa-hidden"> | |
| <div class="txa-menu-back" data-back="main"><i class="fas fa-chevron-left"></i> Speed</div> | |
| <div class="txa-menu-item" data-speed="0.25">0.25x</div> | |
| <div class="txa-menu-item" data-speed="0.5">0.5x</div> | |
| <div class="txa-menu-item" data-speed="0.75">0.75x</div> | |
| <div class="txa-menu-item active" data-speed="1">Normal</div> | |
| <div class="txa-menu-item" data-speed="1.25">1.25x</div> | |
| <div class="txa-menu-item" data-speed="1.5">1.5x</div> | |
| <div class="txa-menu-item" data-speed="1.75">1.75x</div> | |
| <div class="txa-menu-item" data-speed="2">2x</div> | |
| <div class="txa-menu-item" data-speed="2.5">2.5x</div> | |
| <div class="txa-menu-item" data-speed="3">3x</div> | |
| </div> | |
| <div id="txa-view-subs" class="txa-hidden"> | |
| <div class="txa-menu-back" data-back="main"><i class="fas fa-chevron-left"></i> Subtitles</div> | |
| <div id="txa-list-subs"></div> | |
| <div class="txa-menu-item" data-goto="substyle" id="txa-item-substyle" style="border-top:1px solid var(--txa-border);margin-top:8px;"><span><i class="fas fa-paint-brush" style="margin-right:8px;opacity:0.7;"></i>Thiết kế phụ đề</span><i class="fas fa-chevron-right" style="font-size:12px;opacity:0.5;"></i></div> | |
| </div> | |
| <div id="txa-view-substyle" class="txa-hidden"> | |
| <div class="txa-menu-back" data-back="subs"><i class="fas fa-chevron-left"></i> Thiết kế phụ đề</div> | |
| <div style="padding:14px 20px 8px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;">Cỡ chữ</div> | |
| <div class="txa-grid-opts"> | |
| <div class="txa-grid-item" data-subsize="mini">Tí hon</div> | |
| <div class="txa-grid-item" data-subsize="small">Nhỏ</div> | |
| <div class="txa-grid-item active" data-subsize="medium">Vừa</div> | |
| <div class="txa-grid-item" data-subsize="large">Lớn</div> | |
| <div class="txa-grid-item" data-subsize="jumbo" style="grid-column:span 2">Khổng lồ</div> | |
| </div> | |
| <div style="padding:0 20px 8px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;border-top:1px solid var(--txa-border);margin-top:4px;padding-top:14px;">Độ đậm</div> | |
| <div class="txa-grid-opts"> | |
| <div class="txa-grid-item active" data-subweight="normal">Thường</div> | |
| <div class="txa-grid-item" data-subweight="bold">Đậm</div> | |
| </div> | |
| <div style="padding:0 20px 8px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;border-top:1px solid var(--txa-border);margin-top:4px;padding-top:14px;">Nền phụ đề</div> | |
| <div class="txa-grid-opts" style="grid-template-columns: 1fr 1fr 1fr;"> | |
| <div class="txa-grid-item" data-subbg="none">Không</div> | |
| <div class="txa-grid-item active" data-subbg="default">Chuẩn</div> | |
| <div class="txa-grid-item" data-subbg="glass">Kính</div> | |
| <div class="txa-grid-item" data-subbg="dark">Tối</div> | |
| <div class="txa-grid-item" data-subbg="light">Sáng</div> | |
| </div> | |
| <div style="padding:0 20px 10px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;border-top:1px solid var(--txa-border);margin-top:4px;padding-top:14px;">Màu chữ</div> | |
| <div class="txa-color-opts" style="flex-wrap:wrap; gap:8px 12px;"> | |
| <div class="txa-color-btn active" data-subcolor="white" title="Trắng"></div> | |
| <div class="txa-color-btn" data-subcolor="yellow" style="background:#facc15" title="Vàng"></div> | |
| <div class="txa-color-btn" data-subcolor="orange" style="background:#fb923c" title="Cam"></div> | |
| <div class="txa-color-btn" data-subcolor="red" style="background:#f87171" title="Đỏ"></div> | |
| <div class="txa-color-btn" data-subcolor="green" style="background:#4ade80" title="Lục"></div> | |
| <div class="txa-color-btn" data-subcolor="cyan" style="background:#22d3ee" title="Lam"></div> | |
| <div class="txa-color-btn" data-subcolor="pink" style="background:#f472b6" title="Hồng"></div> | |
| <div class="txa-color-btn" data-subcolor="violet" style="background:#a78bfa" title="Tím"></div> | |
| </div> | |
| <div style="padding:14px 20px 8px;font-size:11px;color:var(--txa-brand);font-weight:800;text-transform:uppercase;letter-spacing:1px;border-top:1px solid var(--txa-border);margin-top:4px;padding-top:14px;display:flex;justify-content:space-between;align-items:center;"> | |
| Viền chữ | |
| <label class="txa-switch"><input type="checkbox" id="txa-substroke-toggle"><span class="txa-slider"></span></label> | |
| </div> | |
| <div class="txa-stroke-ctrl" id="txa-stroke-panel"> | |
| <div style="padding:0 20px 8px;font-size:11px;color:#94a3b8;font-weight:800;text-transform:uppercase;letter-spacing:1px;">Màu viền</div> | |
| <div class="txa-color-opts" style="flex-wrap:wrap; gap:8px 12px; padding-bottom:15px;"> | |
| <div class="txa-color-btn active" data-substroke="black" style="background:#000; border:1px solid rgba(255,255,255,0.2);" title="Đen"></div> | |
| <div class="txa-color-btn" data-substroke="white" style="background:#fff" title="Trắng"></div> | |
| <div class="txa-color-btn" data-substroke="yellow" style="background:#facc15" title="Vàng"></div> | |
| <div class="txa-color-btn" data-substroke="red" style="background:#f87171" title="Đỏ"></div> | |
| <div class="txa-color-btn" data-substroke="blue" style="background:#3b82f6" title="Xanh"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="txa-view-quality" class="txa-hidden"> | |
| <div class="txa-menu-back" data-back="main"><i class="fas fa-chevron-left"></i> Quality</div> | |
| <div id="txa-list-quality"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="txa-tamper" class="txa-loader txa-hidden" style="background:rgba(10,10,15,0.95); z-index:2147483647; backdrop-filter:blur(30px); pointer-events:all; transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);"> | |
| <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;color:#fff;text-align:center;padding:40px; transform: scale(1); animation: txaStatsIn 0.6s cubic-bezier(0.16, 1, 0.3, 1);"> | |
| <div style="width:100px;height:100px;background:linear-gradient(135deg, var(--txa-brand), #f472b6);border-radius:32px;display:flex;align-items:center;justify-content:center;margin-bottom:30px;box-shadow:0 20px 50px rgba(139,92,246,0.4); transform: rotate(-5deg);"> | |
| <i class="fas fa-shield-alt" style="font-size:45px;color:#fff; filter: drop-shadow(0 0 10px rgba(255,255,255,0.5));"></i> | |
| </div> | |
| <h2 style="margin:0 0 15px;font-size:32px;background:linear-gradient(to right, #fff, #a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;font-weight:900; letter-spacing: -1px;">BẢO MẬT HỆ THỐNG</h2> | |
| <p style="color:#94a3b8;max-width:500px;margin:0 0 35px;line-height:1.7;font-size:16px; font-weight:500;"> | |
| Phát hiện hành động can thiệp trái phép vào trình phát. | |
| <br/> | |
| <span style="color:var(--txa-brand-light); font-weight: 700;">TPHIMX Security</span> đã tạm khóa phiên làm việc này để bảo vệ nội dung. | |
| </p> | |
| <div style="display:flex;gap:15px;"> | |
| <button onclick="location.reload()" style="background:var(--txa-brand);border:none;color:#fff;padding:16px 40px;border-radius:14px;font-weight:900;font-size:15px;cursor:pointer;box-shadow:0 15px 35px var(--txa-brand-glow);transition:all 0.3s; letter-spacing: 0.5px;" onmouseover="this.style.transform='translateY(-3px) scale(1.05)'; this.style.boxShadow='0 20px 45px var(--txa-brand-glow)';" onmouseout="this.style.transform='translateY(0) scale(1)'; this.style.boxShadow='0 15px 35px var(--txa-brand-glow)';"> | |
| <i class="fas fa-sync-alt" style="margin-right:10px;"></i> TẢI LẠI TRANG | |
| </button> | |
| </div> | |
| <div style="margin-top:50px;font-size:12px;color:rgba(255,255,255,0.3);letter-spacing:2px; font-weight: 800;">PROTECTED BY TXA SHIELD v6.5</div> | |
| </div> | |
| </div> | |
| <div id="txa-ctx" class="txa-ctx"> | |
| <div class="txa-ctx-header">TXAPlayer Controls</div> | |
| <div class="txa-ctx-item" id="txa-ctx-snap"><span>Chụp màn hình (4K)</span><i class="fas fa-camera"></i></div> | |
| <div class="txa-ctx-item" id="txa-ctx-url"><span>Copy Video URL</span><i class="fas fa-link"></i></div> | |
| <div class="txa-ctx-item" id="txa-ctx-loop"><span>Lặp lại (Loop)</span><span id="txa-loop-stat">Off</span></div> | |
| <div class="txa-ctx-item" id="txa-ctx-stats"><span>Thống kê (Stats)</span><i class="fas fa-chart-line"></i></div> | |
| <div class="txa-ctx-divider"></div> | |
| <div class="txa-ctx-item" id="txa-ctx-sc"><span>Bảng phím tắt</span><i class="fas fa-keyboard"></i></div> | |
| <div class="txa-ctx-footer">${TXA_VERSION} Platinum</div> | |
| </div> | |
| <div class="txa-zone left" id="txa-zone-l"></div> | |
| <div class="txa-zone right" id="txa-zone-r"></div> | |
| </div>`; | |
| this.wrapper = this.container.querySelector('#txa-wrapper'); | |
| this.video = this.container.querySelector('#txa-video'); | |
| this.controls = this.container.querySelector('.txa-controls'); | |
| } | |
| async switchEpisode(newOptions = {}) { | |
| // console.log('[TXAPlayer] Switching episode...', newOptions); | |
| // Save fullscreen state before switching | |
| const wasFullscreen = !!document.fullscreenElement; | |
| // Store reference to wrapper, not fullscreenElement (which will be null after video destroy) | |
| const wrapperToFS = this.wrapper; | |
| // 0. Save current progress | |
| this.saveWatchProgress(true); | |
| // Feedback immediately | |
| if (newOptions.isAutoNext) { | |
| this.showToast('⏭️ Tự động chuyển tập...'); | |
| } else { | |
| this.showToast('🚀 Đang chuyển tập mới...'); | |
| } | |
| // 1. Cleanup current media | |
| if (this._bufferInterval) { clearInterval(this._bufferInterval); this._bufferInterval = null; } | |
| if (this.dashPlayer) { | |
| try { this.dashPlayer.destroy(); } catch (e) { } | |
| this.dashPlayer = null; | |
| } | |
| if (this.hls) { | |
| try { this.hls.destroy(); } catch (e) { } | |
| this.hls = null; | |
| } | |
| if (this._previewHls) { | |
| try { this._previewHls.destroy(); } catch (e) { } | |
| this._previewHls = null; | |
| } | |
| // Reset video state | |
| if (this.video) { | |
| this.video.pause(); | |
| this.video.src = ""; | |
| this.video.load(); | |
| // Clear text tracks (subtitles) | |
| while (this.video.firstChild) { | |
| this.video.removeChild(this.video.firstChild); | |
| } | |
| } | |
| // 2. Update Options | |
| this.options = { ...this.options, ...newOptions }; | |
| // Proxy external images for new episode | |
| if (this.options.posterUrl) this.options.posterUrl = this.getProxiedUrl(this.options.posterUrl); | |
| if (this.options.movieThumb) this.options.movieThumb = this.getProxiedUrl(this.options.movieThumb); | |
| if (this.options.previewUrl) this.options.previewUrl = this.getProxiedUrl(this.options.previewUrl); | |
| // Clear runtime state | |
| this.currentSubCues = []; | |
| this.activeSubIdx = -1; | |
| this.seekAccumulator = 0; | |
| this._isAutoNextTriggered = false; | |
| this._fixedTickerShown = false; // Reset fixed ticker flag | |
| this._endingTickerShown = false; // Reset ending ticker flag | |
| if (this.wrapper) this.wrapper.classList.remove('has-countdown'); | |
| this.tickerCount = 0; // Reset ticker count for new episode | |
| this._isTickerRunning = false; | |
| this.tickerNextTime = 40; // Wait 40s for new episode ticker | |
| this.resumeShown = false; // Reset resume flag for new episode | |
| // Clear any pending auto-next countdown | |
| if (this._autoNextCountdownInterval) { | |
| clearInterval(this._autoNextCountdownInterval); | |
| this._autoNextCountdownInterval = null; | |
| } | |
| // Reset markers on UI immediately | |
| this.renderMarkers(); | |
| // Reset preview thumbnail state for new episode | |
| this._previewSpriteUrl = null; | |
| this._previewSpriteLoaded = false; | |
| this._previewLowUrl = null; | |
| this._previewLowLoaded = false; | |
| if (this._previewUpgradeTimer) { clearTimeout(this._previewUpgradeTimer); this._previewUpgradeTimer = null; } | |
| const previewThumb = this.container.querySelector('#txa-preview-thumb'); | |
| if (previewThumb) { previewThumb.classList.remove('has-sprite', 'loading'); previewThumb.style.backgroundImage = ''; } | |
| // Reset UI Components | |
| const replay = this.container.querySelector('#txa-replay'); | |
| if (replay) replay.classList.remove('show'); | |
| const loader = this.container.querySelector('#txa-loader'); | |
| if (loader) loader.classList.remove('hidden'); | |
| // Update Top Title | |
| this.updateTopTitle(); | |
| // 3. Re-initialize Media | |
| try { | |
| await this.setupMedia(); | |
| } catch (error) { | |
| console.error('[TXAPlayer] setupMedia failed:', error); | |
| this.reportErrorToServer('setupMediaError', error.message, { stack: error.stack }); | |
| // Keep loader hidden, show error | |
| if (loader) loader.classList.add('hidden'); | |
| this.showToast('❌ Lỗi load video. Đang thử lại...'); | |
| // Retry after 2 seconds | |
| setTimeout(() => { | |
| if (!this.destroyed) { | |
| console.log('[TXAPlayer] Retrying setupMedia...'); | |
| this.setupMedia().catch(err => { | |
| console.error('[TXAPlayer] Retry failed:', err); | |
| this.reportErrorToServer('setupMediaError', 'Retry failed: ' + err.message, { stack: err.stack }); | |
| this.showToast('❌ Không thể load video. Vui lòng reload trang.'); | |
| }); | |
| } | |
| }, 2000); | |
| return; // Exit early, don't show feedback | |
| } | |
| // 4. Re-initialize Subtitles UI (not just tracks) | |
| this.initSubtitles(); | |
| // 5. Update Navigation Buttons | |
| const prevBtn = this.container.querySelector('#txa-prev'); | |
| const nextBtn = this.container.querySelector('#txa-next'); | |
| if (prevBtn) { | |
| prevBtn.style.display = this.options.prevEpisodeUrl ? 'flex' : 'none'; | |
| } | |
| if (nextBtn) { | |
| nextBtn.style.display = this.options.nextEpisodeUrl ? 'flex' : 'none'; | |
| } | |
| // 6. Wait for video to be ready before hiding loader | |
| await new Promise((resolve) => { | |
| const checkReady = () => { | |
| // Check if video has valid duration and readyState | |
| if (this.video && this.video.readyState >= 2) { | |
| // HAVE_CURRENT_DATA - enough data to play | |
| resolve(); | |
| } else if (this.destroyed) { | |
| resolve(); // Don't wait if destroyed | |
| } else { | |
| // Wait a bit and check again | |
| setTimeout(checkReady, 100); | |
| } | |
| }; | |
| // Timeout after 15 seconds | |
| setTimeout(() => { | |
| console.warn('[TXAPlayer] Video ready timeout, proceeding anyway'); | |
| resolve(); | |
| }, 15000); | |
| checkReady(); | |
| }); | |
| // 7. Final UI Polish (especially for Full Screen) | |
| this.showUI(); | |
| // Hide loader only after video is ready | |
| if (loader) loader.classList.add('hidden'); | |
| // 8. Restore fullscreen if it was active before switching | |
| if (wasFullscreen && wrapperToFS) { | |
| // Wait a bit for video to be ready before requesting fullscreen | |
| setTimeout(async () => { | |
| try { | |
| await wrapperToFS.requestFullscreen(); | |
| console.log('[TXAPlayer] Fullscreen restored after episode switch'); | |
| } catch (err) { | |
| console.warn('[TXAPlayer] Could not restore fullscreen:', err); | |
| } | |
| }, 500); | |
| } | |
| // 9. Trigger external event if needed | |
| if (this.options.onEpisodeSwitched) { | |
| this.options.onEpisodeSwitched(this.options); | |
| } | |
| // 10. Final Feedback (High Priority) - Only show after video is ready | |
| setTimeout(() => { | |
| if (!this.destroyed) { | |
| this.showFeedback('high', `⏭️ Đã chuyển: ${this.options.episodeName || 'Tập mới'}`); | |
| } | |
| }, 500); | |
| } | |
| updateTopTitle() { | |
| const titleEl = this.container.querySelector('#txa-top-title span'); | |
| if (!titleEl) return; | |
| let movieTitle = this.options.title || 'Đang tải...'; | |
| let episodeTitle = this.options.episodeName || ''; | |
| // Clean up movieTitle if it already contains the episode name to avoid duplication | |
| if (episodeTitle && movieTitle.includes(episodeTitle)) { | |
| // If movieTitle is "Phim A - Tập 1" and episodeTitle is "Tập 1" | |
| // We want titleStr to be "Xem phim: Phim A - Tập 1" | |
| // Instead of "Xem phim: Phim A - Tập 1 - Tập 1" | |
| titleEl.textContent = 'Xem phim: ' + movieTitle + (this.options.serverName ? ' - ' + this.options.serverName : ''); | |
| return; | |
| } | |
| let titleStr = 'Xem phim: ' + movieTitle; | |
| // Handle episode name logic (skip if "Full" or if duplication detected) | |
| if (episodeTitle && !episodeTitle.toLowerCase().includes('full')) { | |
| titleStr += ' - ' + episodeTitle; | |
| } | |
| // Add server name if exists | |
| if (this.options.serverName) { | |
| titleStr += ' - ' + this.options.serverName; | |
| } | |
| titleEl.textContent = titleStr; | |
| } | |
| async getM3u8TsListFast(url) { | |
| // Ensure URL is absolute because proxy URLs might be relative paths (e.g., /api/proxy-media...) | |
| const absoluteUrl = new URL(url, window.location.origin).href; | |
| let response = await fetch(absoluteUrl, { headers: { 'X-EXTENSION-MODE': '1' } }); | |
| if (!response.ok) throw new Error("Fetch m3u8 lỗi."); | |
| let text = await response.text(); | |
| let lines = text.split("\n"); | |
| if (text.includes("METHOD=AES-128")) { | |
| throw new Error("Luồng bị mã hóa AES-128 (Yêu cầu HLS.js để giải mã)."); | |
| } | |
| if (text.includes("EXT-X-STREAM-INF")) { | |
| let variants = []; | |
| for (let i = 0; i < lines.length; i++) { | |
| if (lines[i].includes("EXT-X-STREAM-INF")) { | |
| let info = lines[i]; | |
| let nextLine = lines[i + 1]; | |
| if (nextLine && !nextLine.startsWith("#")) { | |
| let bandwidth = 0; | |
| const bwMatch = info.match(/BANDWIDTH=(\d+)/); | |
| if (bwMatch) bandwidth = parseInt(bwMatch[1]); | |
| let resolution = "0x0"; | |
| const resMatch = info.match(/RESOLUTION=(\d+x\d+)/); | |
| if (resMatch) resolution = resMatch[1]; | |
| variants.push({ | |
| url: new URL(nextLine.trim(), absoluteUrl).href, | |
| bandwidth: bandwidth, | |
| resolution: resolution | |
| }); | |
| } | |
| } | |
| } | |
| if (variants.length > 0) { | |
| // Sort by bandwidth descending (best quality first) | |
| variants.sort((a, b) => b.bandwidth - a.bandwidth); | |
| if (this.options.isAdmin) console.log('[TXAPlayer] High Quality Variant Selected:', variants[0]); | |
| return await this.getM3u8TsListFast(variants[0].url); | |
| } | |
| } | |
| let tsUrls = []; | |
| let initUrl = null; | |
| let totalDuration = 0; | |
| for (let i = 0; i < lines.length; i++) { | |
| let line = lines[i].trim(); | |
| if (line.startsWith("#EXTINF:")) { | |
| let dur = parseFloat(line.split(":")[1]); | |
| if (!isNaN(dur)) totalDuration += dur; | |
| } | |
| if (line.startsWith("#EXT-X-MAP:URI=")) { | |
| const mapMatch = line.match(/URI="([^"]+)"/); | |
| if (mapMatch) initUrl = new URL(mapMatch[1], absoluteUrl).href; | |
| } | |
| if (line && !line.startsWith("#")) { | |
| tsUrls.push(new URL(line, absoluteUrl).href); | |
| } | |
| } | |
| if (initUrl) { | |
| tsUrls.unshift(initUrl); // Prepend init map for FMP4 | |
| } | |
| // Estimate 1.5 Mbps bitrate for size calculation | |
| const estimatedSize = totalDuration * (1.5 * 1024 * 1024 / 8); | |
| return { urls: tsUrls, duration: totalDuration, estimatedSize: estimatedSize }; | |
| } | |
| async loadM3u8AsMP4(m3u8Url, updateLoadingPct, completeLoading, showLoaderWithProgress) { | |
| try { | |
| const loadText = this.container.querySelector('#txa-load-text'); | |
| const pctEl = this.container.querySelector('#txa-load-pct'); | |
| const speedEl = this.container.querySelector('#txa-load-speed'); | |
| const loadInfoEl = this.container.querySelector('#txa-load-info'); | |
| // --- CACHE CHECK --- | |
| const cacheKey = this.options.episodeId ? `ep-${this.options.episodeId}` : null; | |
| if (cacheKey) { | |
| if (loadText) loadText.textContent = "🔍 Đang kiểm tra bộ nhớ tạm (Cache)..."; | |
| const cached = await txaStorage.get(cacheKey); | |
| if (cached && (cached.m3u8Url === m3u8Url || m3u8Url.includes('proxy-media'))) { | |
| if (loadText) loadText.textContent = "⚡ Phát từ Cache (Siêu tốc)!"; | |
| updateLoadingPct(100, true); | |
| completeLoading(); | |
| const objectUrl = URL.createObjectURL(cached.data); | |
| this.video.src = objectUrl; | |
| if (this.options.autoPlay) this.video.play().catch(() => { }); | |
| return; | |
| } | |
| } | |
| if (loadText) loadText.textContent = "⚙️ Đang phân tích luồng HLS..."; | |
| showLoaderWithProgress(5); | |
| const m3u8Result = await this.getM3u8TsListFast(m3u8Url); | |
| let tsList = m3u8Result.urls; | |
| if (!tsList || tsList.length === 0) throw new Error("Không có file TS trong playlist!"); | |
| let total = tsList.length; | |
| let mergedBuffers = []; | |
| let totalBytes = 0; | |
| let startTime = performance.now(); | |
| let lastUpdate = performance.now(); | |
| if (loadInfoEl) { | |
| const sMB = window.txaformat ? window.txaformat.fileSize(m3u8Result.estimatedSize, 1) : (m3u8Result.estimatedSize / (1024 * 1024)).toFixed(1) + " MB"; | |
| const durStr = window.txaformat ? window.txaformat.timeRemaining(m3u8Result.duration) : (m3u8Result.duration / 60).toFixed(0) + " phút"; | |
| loadInfoEl.innerHTML = ` | |
| <span class="txa-load-info-tag"><i class="fas fa-clock"></i> <span class="tag-val">${durStr}</span></span> | |
| <span class="txa-load-info-tag"><i class="fas fa-hdd"></i> <span class="tag-val">~${sMB}</span></span> | |
| <span class="txa-load-info-tag bg-warning bg-opacity-25 text-warning"><i class="fas fa-crown"></i> <span class="tag-val">PREMIUM BLOB ACTIVE</span></span> | |
| `; | |
| } | |
| // Batch size for parallel downloads (Turbo Mode: 17) | |
| const batchSize = 17; | |
| for (let i = 0; i < total; i += batchSize) { | |
| const batch = tsList.slice(i, i + batchSize); | |
| const promises = batch.map(async (ts, idx) => { | |
| const r = await fetch(ts, { | |
| headers: { 'X-EXTENSION-MODE': '1' } | |
| }); | |
| if (!r.ok) throw new Error(`Fetch TS ${i + idx} fail: ${r.status}`); | |
| const buf = await r.arrayBuffer(); | |
| totalBytes += buf.byteLength; | |
| // Periodic stats update | |
| const now = performance.now(); | |
| if (now - lastUpdate > 500) { | |
| const elapsed = (now - startTime) / 1000; | |
| const speed = totalBytes / elapsed; | |
| const remaining = total - (i + idx); | |
| const avgTimePerTs = elapsed / (i + idx + 1); | |
| const etaSec = Math.round(remaining * avgTimePerTs); | |
| if (speedEl) { | |
| const speedStr = window.txaformat ? window.txaformat.fileSize(speed, 1) + "/s" : (speed / 1048576).toFixed(1) + " MB/s"; | |
| const etaStr = window.txaformat ? window.txaformat.timeRemaining(etaSec) : (etaSec > 60 ? Math.floor(etaSec / 60) + "m " + (etaSec % 60) + "s" : etaSec + "s"); | |
| speedEl.innerHTML = `<span class="text-white">${speedStr}</span> <span class="ms-2 opacity-50">ETA: ${etaStr}</span>`; | |
| } | |
| if (loadText) loadText.textContent = `🚀 [TSHORT] Đang tải seg ${i + idx + 1}/${total}...`; | |
| lastUpdate = now; | |
| } | |
| return buf; | |
| }); | |
| const buffers = await Promise.all(promises); | |
| mergedBuffers.push(...buffers); | |
| const progress = 10 + Math.floor((Math.min(i + batchSize, total) / total) * 85); | |
| updateLoadingPct(progress, true); | |
| } | |
| if (loadText) loadText.textContent = "🛠️ Đang lắp ráp Video MP4..."; | |
| const isFmp4 = tsList[0].includes('.m4s') || tsList[0].includes('.mp4'); | |
| const blobType = isFmp4 ? 'video/mp4' : 'video/mp2t'; | |
| const combinedBlob = new Blob(mergedBuffers, { type: blobType }); | |
| // --- SAVE TO CACHE --- | |
| if (cacheKey) { | |
| txaStorage.set(cacheKey, combinedBlob, m3u8Url); | |
| } | |
| const objectUrl = URL.createObjectURL(combinedBlob); | |
| if (loadText) loadText.textContent = "✨ Sẵn sàng phát (Premium)!"; | |
| updateLoadingPct(100, true); | |
| completeLoading(); | |
| this.video.src = objectUrl; | |
| if (this.options.autoPlay) this.video.play().catch(() => { }); | |
| } catch (e) { | |
| console.error("[TXAPlayer Premium] Error: ", e); | |
| if (window.txatoastfy) window.txatoastfy.error('Anti-403 Mode thất bại: ' + e.message, 5000); | |
| // Fallback to standard HLS | |
| const loadText = this.container.querySelector('#txa-load-text'); | |
| if (loadText) loadText.textContent = "⚠️ Lỗi Anti-403, đang rollback về HLS tiêu chuẩn..."; | |
| setTimeout(() => { | |
| this._initStandardHls(); | |
| }, 2000); | |
| } | |
| } | |
| async setupMedia() { | |
| if (!this.video || this._mediaSetupDone) return; | |
| this._mediaSetupDone = true; | |
| // --- Google Drive Auto-Resolution --- | |
| // Detects Google Drive sharing links and converts to proxy stream URL | |
| // Supported formats: | |
| // https://drive.google.com/file/d/FILE_ID/view?usp=... | |
| // https://drive.google.com/file/d/FILE_ID/preview | |
| // https://drive.google.com/open?id=FILE_ID | |
| // https://drive.google.com/uc?id=FILE_ID&export=download | |
| if (this.options.videoUrl && this.options.videoUrl.includes('drive.google.com')) { | |
| let driveFileId = null; | |
| // Pattern 1: /file/d/FILE_ID/ | |
| const fileMatch = this.options.videoUrl.match(/\/file\/d\/([a-zA-Z0-9_-]+)/); | |
| if (fileMatch) { | |
| driveFileId = fileMatch[1]; | |
| } | |
| // Pattern 2: ?id=FILE_ID or &id=FILE_ID | |
| if (!driveFileId) { | |
| try { | |
| const u = new URL(this.options.videoUrl); | |
| driveFileId = u.searchParams.get('id'); | |
| } catch (_) { } | |
| } | |
| if (driveFileId) { | |
| // Convert to local proxy stream URL (supports HTTP Range seeking) | |
| const proxyUrl = '/api/drive-stream/' + driveFileId; | |
| console.log( | |
| '%c[TXAPlayer] 📁 Google Drive Detected ✅', | |
| 'color:#4285f4; font-weight:bold;', | |
| { fileId: driveFileId, proxyUrl } | |
| ); | |
| this.options.videoUrl = proxyUrl; | |
| // Show status on loader | |
| const _loadText = this.container?.querySelector('#txa-load-text'); | |
| if (_loadText) _loadText.textContent = 'Đang kết nối Google Drive...'; | |
| } | |
| } | |
| // --- StreamC Auto-Resolution (Dynamic Subdomain Support) --- | |
| if (this.options.videoUrl && (this.options.videoUrl.includes('streamc.xyz') || this.options.videoUrl.includes('streamc.top') || /^[a-f0-9]{32}$/i.test(this.options.videoUrl))) { | |
| let hash = null; | |
| let embedHost = ''; | |
| if (/^[a-f0-9]{32}$/i.test(this.options.videoUrl)) { | |
| hash = this.options.videoUrl; | |
| } else if (this.options.videoUrl.includes('hash=')) { | |
| try { | |
| const u = new URL(this.options.videoUrl); | |
| hash = u.searchParams.get('hash'); | |
| embedHost = u.hostname; // e.g., embed18.streamc.xyz | |
| embedHost = u.hostname; | |
| } catch (_) { } | |
| } else { | |
| const m = this.options.videoUrl.match(/\/([a-f0-9]{32})\//i); | |
| if (m) hash = m[1]; | |
| } | |
| if (!embedHost && this.options.videoUrl.includes('streamc')) { | |
| const hm = this.options.videoUrl.match(/(embed\d*\.streamc\.(?:xyz|top))/i); | |
| if (hm) embedHost = hm[1]; | |
| } | |
| if (hash) { | |
| // ── Hiển thị trạng thái đang resolve trên loader ── | |
| const _loadText = this.container?.querySelector('#txa-load-text'); | |
| if (_loadText) _loadText.textContent = 'Đang kết nối nguồn phim...'; | |
| /** Hiện error overlay thay vì để loader treo mãi */ | |
| const _showStreamCError = (msg, canRetry = true) => { | |
| // Report resolve error to server | |
| this.reportErrorToServer('resolveError', msg, { hash, embedHost }); | |
| const loader = this.container?.querySelector('#txa-loader'); | |
| if (!loader) return; | |
| const pctEl = loader.querySelector('#txa-load-pct'); | |
| const barFill = loader.querySelector('#txa-load-bar-fill'); | |
| const spinner = loader.querySelector('.txa-spinner'); | |
| const loadText = loader.querySelector('#txa-load-text'); | |
| const speedEl = loader.querySelector('#txa-load-speed'); | |
| // Đổi giao diện loader → error state | |
| if (spinner) { spinner.style.borderTopColor = '#ef4444'; spinner.style.animationPlayState = 'paused'; } | |
| if (pctEl) { pctEl.textContent = '!'; pctEl.style.color = '#ef4444'; } | |
| if (barFill) { barFill.style.background = '#ef4444'; barFill.style.width = '100%'; barFill.style.boxShadow = '0 0 10px #ef4444'; } | |
| if (speedEl) speedEl.textContent = ''; | |
| if (loadText) { | |
| loadText.style.color = '#fca5a5'; | |
| loadText.innerHTML = `<i class="fas fa-exclamation-triangle" style="color:#ef4444; margin-right:6px;"></i>${msg}`; | |
| } | |
| // Thêm nút Retry nếu chưa có | |
| if (canRetry && !loader.querySelector('#txa-retry-btn')) { | |
| const retryBtn = document.createElement('button'); | |
| retryBtn.id = 'txa-retry-btn'; | |
| retryBtn.innerHTML = '<i class="fas fa-redo" style="margin-right:6px;"></i>Thử lại'; | |
| retryBtn.style.cssText = ` | |
| margin-top:14px; padding:8px 22px; | |
| background:#3b82f6; color:#fff; border:none; | |
| border-radius:8px; cursor:pointer; font-size:13px; font-weight:600; | |
| pointer-events:all; | |
| `; | |
| retryBtn.onmouseenter = () => retryBtn.style.background = '#2563eb'; | |
| retryBtn.onmouseleave = () => retryBtn.style.background = '#3b82f6'; | |
| retryBtn.onclick = () => window.location.reload(); | |
| const content = loader.querySelector('.txa-loader-content'); | |
| if (content) content.appendChild(retryBtn); | |
| // Đảm bảo loader nhận pointer events để click được | |
| loader.style.pointerEvents = 'all'; | |
| } | |
| }; | |
| try { | |
| let resolveUrl = '/api/resolve-streamc?hash=' + encodeURIComponent(hash); | |
| if (embedHost) resolveUrl += '&embed_host=' + encodeURIComponent(embedHost); | |
| // Timeout 20s — nếu server không trả lời thì abort | |
| const controller = new AbortController(); | |
| const resolveTimeout = setTimeout(() => controller.abort(), 20000); | |
| let res; | |
| try { | |
| res = await fetch(resolveUrl, { signal: controller.signal }); | |
| } finally { | |
| clearTimeout(resolveTimeout); | |
| } | |
| // HTTP error (502, 403, 500...) | |
| if (!res.ok) { | |
| const errMsg = res.status === 502 | |
| ? 'Nguồn phim đang bảo trì hoặc bị chặn. Hãy thử nguồn khác.' | |
| : res.status === 403 | |
| ? 'Truy cập bị từ chối. Hãy thử nguồn khác.' | |
| : `Lỗi kết nối (HTTP ${res.status}). Thử lại sau.`; | |
| console.error(`[TXAPlayer] StreamC HTTP ${res.status} ❌`, { hash, embedHost }); | |
| _showStreamCError(errMsg, true); | |
| return; // Dừng setupMedia — không tiếp tục khởi tạo HLS với URL rỗng | |
| } | |
| const data = await res.json(); | |
| const checkBlob = this.options.anti403Mode === 'blob' || this.options.videoUrl.includes('anti403=1'); | |
| if (data.success) { | |
| if (checkBlob && data.raw_url) { | |
| this.options.videoUrl = data.raw_url; | |
| } else if (data.video_url) { | |
| this.options.videoUrl = data.video_url; | |
| if (this.options.isAdmin) { | |
| console.log('%c[TXAPlayer] StreamC Resolved ✅', 'color:#10b981;font-weight:bold;', { | |
| hash, embedHost: data.embed_host, method: data.method, | |
| proxyUrl: data.video_url.substring(0, 80) + '...' | |
| }); | |
| } | |
| } | |
| } else { | |
| const reason = data.message || data.error || 'Không xác định'; | |
| console.error('[TXAPlayer] StreamC Resolve Failed ❌', { hash, embedHost, error: reason }); | |
| _showStreamCError('Không thể kết nối nguồn phim. ' + reason, true); | |
| return; // Dừng — không khởi tạo HLS với URL không hợp lệ | |
| } | |
| } catch (e) { | |
| if (e.name === 'AbortError') { | |
| console.error('[TXAPlayer] StreamC Resolve Timeout (20s) ❌', { hash, embedHost }); | |
| _showStreamCError('Nguồn phim phản hồi quá chậm. Hãy thử lại.', true); | |
| } else { | |
| console.error('[TXAPlayer] StreamC Resolve Error ❌', e); | |
| _showStreamCError('Lỗi mạng khi kết nối nguồn phim.', true); | |
| } | |
| return; // Dừng — không tiếp tục HLS setup | |
| } | |
| } | |
| } | |
| // Auto-proxy logic for known SSL-untrusted domains | |
| if (this.options.videoUrl && (this.options.videoUrl.includes('phimmoi.net') || this.options.videoUrl.includes('sing.phimmoi.net') || this.options.videoUrl.includes('streamvda.top'))) { | |
| if (this.options.videoUrl.includes('?url=')) { | |
| console.log( | |
| '%c[TXAPlayer] 🛡️ SSL Guard Bypass: Already Proxied via Cloudflare/Vercel Proxy ✅', | |
| 'color:#10b981; font-weight:bold;', | |
| { videoUrl: this.options.videoUrl } | |
| ); | |
| } | |
| if (!this.options.videoUrl.includes('/api/proxy-media') && !this.options.videoUrl.includes('/api/video/') && !this.options.videoUrl.includes('?url=')) { | |
| try { | |
| console.log('%c[TXAPlayer] 🛡️ SSL Guard Active: Resolving video URL...', 'color:#f59e0b; font-weight:bold;'); | |
| const r = await fetch(`/api/video/resolve?url=${encodeURIComponent(btoa(this.options.videoUrl))}`); | |
| const data = await r.json(); | |
| if (data.success && data.url) { | |
| this.options.videoUrl = data.url; | |
| console.log('%c[TXAPlayer] 🛡️ SSL Guard Resolved URL ✅', 'color:#10b981; font-weight:bold;', { resolvedUrl: this.options.videoUrl }); | |
| } | |
| } catch (e) { console.error('[TXAPlayer] SSL Guard Fail', e); } | |
| } | |
| } | |
| const loader = this.container.querySelector('#txa-loader'); | |
| const pctEl = this.container.querySelector('#txa-load-pct'); | |
| const speedEl = this.container.querySelector('#txa-load-speed'); | |
| const barFill = this.container.querySelector('#txa-load-bar-fill'); | |
| const loadInfoEl = this.container.querySelector('#txa-load-info'); | |
| let loadProgress = 0, loadInterval = null, lastBytes = 0, lastSpeedTime = performance.now(); | |
| let speedHistory = [], isHLS = false; | |
| // Populate loading info tags | |
| if (loadInfoEl) { | |
| const tags = []; | |
| if (this.options.title) tags.push({ icon: 'fa-film', label: this.options.title }); | |
| if (this.options.episodeName) tags.push({ icon: 'fa-list', label: this.options.episodeName }); | |
| if (this.options.serverName) tags.push({ icon: 'fa-server', label: this.options.serverName }); | |
| const url = this.options.videoUrl || ''; | |
| if (url.includes('.mpd')) tags.push({ icon: 'fa-video', label: 'DASH' }); | |
| else if (url.includes('.m3u8')) tags.push({ icon: 'fa-video', label: 'HLS' }); | |
| else if (url.includes('.mp4')) tags.push({ icon: 'fa-video', label: 'MP4' }); | |
| else if (url.includes('/api/drive-stream/')) tags.push({ icon: 'fab fa-google-drive', label: 'Google Drive' }); | |
| loadInfoEl.innerHTML = tags.map(t => `<span class="txa-load-info-tag"><i class="fas ${t.icon}"></i> <span class="tag-val">${t.label}</span></span>`).join(''); | |
| } | |
| const updateSpeed = (n) => { | |
| const now = performance.now(), elapsed = (now - lastSpeedTime) / 1000; | |
| if (elapsed > 0.2 && n > lastBytes) { | |
| const bps = (n - lastBytes) / elapsed; | |
| speedHistory.push(bps); | |
| if (speedHistory.length > 5) speedHistory.shift(); | |
| const currentSpeed = speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; | |
| if (speedEl) speedEl.textContent = (window.txaformat && window.txaformat.fileSize) ? window.txaformat.fileSize(currentSpeed, 1) + '/s' : (currentSpeed / 1048576).toFixed(1) + ' MB/s'; | |
| lastBytes = n; lastSpeedTime = now; | |
| } | |
| }; | |
| const updateLoadingPct = (targetPct, instant = false) => { | |
| if (!pctEl) return; | |
| const loadText = this.container.querySelector('#txa-load-text'); | |
| if (instant) { | |
| this._loadProgress = targetPct; | |
| pctEl.textContent = Math.round(this._loadProgress) + '%'; | |
| if (barFill) barFill.style.width = Math.round(this._loadProgress) + '%'; | |
| return; | |
| } | |
| if (this._loadInterval) clearInterval(this._loadInterval); | |
| this._loadInterval = setInterval(() => { | |
| if (this._loadProgress < targetPct) { | |
| const inc = targetPct >= 90 ? 0.5 : (targetPct >= 70 ? 1 : 2); | |
| this._loadProgress = Math.min(targetPct, this._loadProgress + inc); | |
| pctEl.textContent = Math.round(this._loadProgress) + '%'; | |
| if (barFill) barFill.style.width = Math.round(this._loadProgress) + '%'; | |
| if (loadText) { | |
| if (this._loadProgress < 20) loadText.textContent = 'Đang khởi tạo trình phát...'; | |
| else if (this._loadProgress < 40) loadText.textContent = 'Đang tải manifest...'; | |
| else if (this._loadProgress < 70) loadText.textContent = 'Đang đệm dữ liệu...'; | |
| else if (this._loadProgress < 95) loadText.textContent = 'Đang tối ưu hóa...'; | |
| else loadText.textContent = 'Sẵn sàng!'; | |
| } | |
| } else { clearInterval(this._loadInterval); this._loadInterval = null; } | |
| }, 50); | |
| }; | |
| const completeLoading = () => { | |
| if (this._loadInterval) clearInterval(this._loadInterval); | |
| this._loadProgress = 100; | |
| if (pctEl) pctEl.textContent = '100%'; | |
| if (barFill) barFill.style.width = '100%'; | |
| if (speedEl) speedEl.textContent = '✓ Ready'; | |
| setTimeout(() => loader.classList.add('hidden'), 200); | |
| }; | |
| const showLoaderWithProgress = (startPct = null) => { | |
| // v6.6.1 - Reset loader styles to default (clearing error states) | |
| if (loader) { | |
| const _spin = loader.querySelector('.txa-spinner'); | |
| const _pct = loader.querySelector('#txa-load-pct'); | |
| const _bar = loader.querySelector('#txa-load-bar-fill'); | |
| const _text = loader.querySelector('#txa-load-text'); | |
| const _retry = loader.querySelector('#txa-retry-btn'); | |
| if (_spin) { _spin.style.borderTopColor = 'var(--txa-brand)'; _spin.style.animationPlayState = 'running'; } | |
| if (_pct) { _pct.style.color = '#fff'; } | |
| if (_bar) { | |
| _bar.style.background = 'linear-gradient(90deg, var(--txa-brand), #f472b6)'; | |
| _bar.style.boxShadow = '0 0 15px var(--txa-brand-glow)'; | |
| } | |
| if (_text) { _text.style.color = 'rgba(255,255,255,0.5)'; } | |
| if (_retry) _retry.remove(); | |
| loader.style.pointerEvents = 'none'; | |
| } | |
| loader?.classList.remove('hidden'); | |
| if (startPct !== null) updateLoadingPct(startPct, true); | |
| }; | |
| // Expose to instance so setQuality can use it | |
| this.updateLoadingPct = updateLoadingPct; | |
| this.hideLoader = completeLoading; | |
| this.showLoader = showLoaderWithProgress; | |
| if (this.options.videoUrl?.endsWith('.mpd')) { | |
| this.dashPlayer = dashjs.MediaPlayer().create(); | |
| this.dashPlayer.initialize(this.video, this.options.videoUrl, this.options.autoPlay); | |
| updateLoadingPct(10); | |
| this.dashPlayer.on('streamInitialized', () => { | |
| updateLoadingPct(80); | |
| this.container.querySelector('#txa-item-quality').style.display = 'flex'; | |
| this.generateQualityList(); this.initSubtitles(); this.renderMarkers(); | |
| }); | |
| this.dashPlayer.on('canPlay', completeLoading); | |
| this.dashPlayer.on('bufferStalled', () => showLoaderWithProgress(loadProgress > 50 ? loadProgress : 50)); | |
| this.dashPlayer.on('bufferLoaded', completeLoading); | |
| this.dashPlayer.on('fragmentLoadingCompleted', (e) => { if (e?.request?.bytesTotal) updateSpeed(lastBytes + e.request.bytesTotal); }); | |
| } else if (this.options.videoUrl) { | |
| // Updated isHLS detection to be more robust | |
| isHLS = this.options.videoUrl.includes('.m3u8') || | |
| this.options.videoUrl.includes('txaformat=.m3u8') || | |
| this.options.videoUrl.includes('format=.m3u8') || | |
| (this.options.videoUrl.includes('streamc') && this.options.videoUrl.includes('.m3u8')); | |
| if (isHLS && typeof Hls !== 'undefined' && Hls.isSupported()) { | |
| const isStreamC = this.options.videoUrl.includes('streamc'); | |
| const isBlobMode = this.options.anti403Mode === 'blob' || this.options.videoUrl.includes('anti403=1'); | |
| if (this.options.isAdmin) { | |
| console.log('%c[TXAPlayer] HLS Detected', 'color: #3b82f6; font-weight: bold;', this.options.videoUrl); | |
| console.log('%c[TXAPlayer] Anti-403 Mode Strategy:', 'color: #10b981;', this.options.anti403Mode); | |
| } | |
| const initStandardHls = (useExtensionMode = false) => { | |
| if (this.options.isAdmin) console.log('%c[TXAPlayer] Initializing Standard HLS (Proxy Mode)...', 'color: #3b82f6;'); | |
| if (this.hls) this.hls.destroy(); | |
| this.hls = new Hls({ | |
| enableWorker: true, | |
| lowLatencyMode: false, | |
| backBufferLength: 120, | |
| maxBufferLength: 180, | |
| maxMaxBufferLength: 600, | |
| maxBufferSize: 250 * 1000 * 1000, | |
| fragLoadingRetryDelay: 500, | |
| fragLoadingMaxRetry: 10, | |
| manifestLoadingMaxRetry: 5, | |
| manifestLoadingRetryDelay: 1000, | |
| levelLoadingMaxRetry: 5, | |
| fragLoadingTimeOut: 20000, | |
| startLevel: -1, | |
| abandonAudioCodec: true, | |
| fastReadyState: true, | |
| liveDurationInfinity: true, | |
| capLevelToPlayerSize: true, | |
| xhrSetup: (xhr, url) => { | |
| if (useExtensionMode) { | |
| xhr.setRequestHeader('X-EXTENSION-MODE', '1'); | |
| } else { | |
| xhr.setRequestHeader('X-TXA-PLAYER', 'Premium'); | |
| } | |
| } | |
| }); | |
| updateLoadingPct(10); | |
| this.hls.attachMedia(this.video); | |
| this.hls.on(Hls.Events.MEDIA_ATTACHED, () => this.hls.loadSource(this.options.videoUrl)); | |
| this.hls.on(Hls.Events.MANIFEST_LOADING, () => updateLoadingPct(20)); | |
| this.hls.on(Hls.Events.MANIFEST_LOADED, () => updateLoadingPct(40)); | |
| this.hls.on(Hls.Events.LEVEL_LOADING, () => updateLoadingPct(55)); | |
| this.hls.on(Hls.Events.LEVEL_LOADED, () => updateLoadingPct(70)); | |
| this.hls.on(Hls.Events.FRAG_LOADING, () => { if (loadProgress < 80) updateLoadingPct(80); }); | |
| this.hls.on(Hls.Events.FRAG_LOADED, (e, data) => { | |
| updateLoadingPct(90); | |
| if (data?.frag?.stats?.loaded) updateSpeed(lastBytes + data.frag.stats.loaded); | |
| this._updateBufferBar(); | |
| }); | |
| this.hls.on(Hls.Events.MANIFEST_PARSED, () => { | |
| this.renderMarkers(); this.generateQualityList(); | |
| if (this.options.autoPlay) this.video.play().catch(() => { }); | |
| }); | |
| this.hls.on(Hls.Events.LEVEL_SWITCHED, () => { | |
| this.updateQualityUI(this.hls.autoLevelEnabled ? -1 : this.hls.loadLevel); | |
| if (this.hideLoader) this.hideLoader(); // Hide loader after quality switch | |
| }); | |
| this.hls.on(Hls.Events.ERROR, (e, data) => { | |
| console.warn('[TXAPlayer] HLS Error', data.type, data.details, data.fatal); | |
| if (data.fatal) { | |
| if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { | |
| // Network error: thử recover, nếu vẫn fail thì hiện lỗi | |
| this._hlsNetworkRetries = (this._hlsNetworkRetries || 0) + 1; | |
| if (this._hlsNetworkRetries <= 3) { | |
| console.warn(`[TXAPlayer] HLS Network Error - Retry ${this._hlsNetworkRetries}/3`); | |
| setTimeout(() => this.hls?.startLoad(), 1500); | |
| } else { | |
| console.error('[TXAPlayer] HLS Fatal Network Error after retries'); | |
| this._txaShowHlsError('Không tải được video. Kiểm tra kết nối mạng.', data); | |
| } | |
| } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { | |
| this._hlsMediaRetries = (this._hlsMediaRetries || 0) + 1; | |
| if (this._hlsMediaRetries <= 2) { | |
| this.hls.recoverMediaError(); | |
| } else { | |
| this._txaShowHlsError('Lỗi giải mã video. Thử tải lại trang.', data); | |
| } | |
| } else { | |
| this._txaShowHlsError('Lỗi không xác định khi phát video.', data); | |
| this.hls.destroy(); | |
| } | |
| } | |
| }); | |
| }; | |
| this._initStandardHls = initStandardHls; | |
| if (isBlobMode) { | |
| if (this.options.isAdmin) console.log('%c[TXAPlayer] 🚀 EXTENSION MODE. Fetching and merging M3U8...', 'color: #10b981; font-weight: bold; font-size: 14px;'); | |
| this.loadM3u8AsMP4(this.options.videoUrl, updateLoadingPct, completeLoading, showLoaderWithProgress); | |
| return; | |
| } | |
| initStandardHls(false); | |
| } else if (isHLS) { | |
| this.showFeedback('high', '❌ Thiếu thư viện HLS.js - Không thể phát M3U8. Vui lòng kiểm tra import HLS.js hoặc dùng link MP4 trực tiếp.'); completeLoading(); | |
| } else { // Regular MP4 or other direct video URL | |
| updateLoadingPct(15); | |
| this.video.src = this.options.videoUrl; | |
| if (this.options.autoPlay) this.video.play().catch(() => { }); | |
| } | |
| this.initSubtitles(); | |
| } | |
| // Loading state management | |
| const hideLoader = () => completeLoading(); | |
| const showLoader = () => showLoaderWithProgress(loadProgress > 30 ? loadProgress : 30); | |
| this.video.onloadstart = () => { | |
| if (loadProgress < 20) updateLoadingPct(20); | |
| }; | |
| this.video.onloadeddata = () => { | |
| updateLoadingPct(60); | |
| }; | |
| this.video.oncanplay = () => { | |
| updateLoadingPct(85); | |
| }; | |
| this.video.oncanplaythrough = () => { | |
| completeLoading(); | |
| }; | |
| this.video.onwaiting = showLoader; | |
| this.video.onseeking = () => { | |
| showLoaderWithProgress(50); | |
| }; | |
| this.video.onseeked = hideLoader; | |
| this.video.onplaying = hideLoader; | |
| this.video.onloadedmetadata = () => { | |
| updateLoadingPct(45); | |
| this.updateTopTitle(); | |
| this.renderMarkers(); | |
| this.checkWatchResume(); | |
| this.initSubtitles(); // Ensure subs are synced after metadata | |
| }; | |
| /** | |
| * ACCURATE BUFFERED PERCENTAGE CALCULATION | |
| */ | |
| this.video.onprogress = () => { | |
| if (!loader.classList.contains('hidden') && pctEl) { | |
| const duration = this.video.duration; | |
| if (!duration || !isFinite(duration)) return; | |
| const buffered = this.video.buffered; | |
| if (buffered.length === 0) return; | |
| let totalBuffered = 0; | |
| for (let i = 0; i < buffered.length; i++) { | |
| totalBuffered += buffered.end(i) - buffered.start(i); | |
| } | |
| const bufferPct = (totalBuffered / duration) * 100; | |
| const mappedPct = 60 + (bufferPct * 0.39); | |
| if (mappedPct > loadProgress) { | |
| updateLoadingPct(Math.min(99, mappedPct)); | |
| } | |
| } | |
| }; | |
| // ===== CONTINUOUS BUFFER BAR UPDATE (YouTube-style) ===== | |
| if (this._bufferInterval) clearInterval(this._bufferInterval); | |
| const bufBar = this.container.querySelector('#txa-buffer'); | |
| if (bufBar) { | |
| bufBar.classList.add('loading'); | |
| this._bufferInterval = setInterval(() => { | |
| this._updateBufferBar(); | |
| }, 1000); | |
| this.video.addEventListener('progress', () => { | |
| const duration = this.video.duration; | |
| if (!duration || !isFinite(duration)) return; | |
| const buffered = this.video.buffered; | |
| if (buffered.length > 0) { | |
| const lastEnd = buffered.end(buffered.length - 1); | |
| if (lastEnd >= duration - 1) { | |
| bufBar.classList.remove('loading'); | |
| } | |
| } | |
| }); | |
| } | |
| // ── Watchdog: nếu progress stuck ở ≤20% sau 18s thì hiển thị lỗi ── | |
| // Bảo vệ người dùng khỏi loader treo vô hạn khi mạng chậm hoặc nguồn lỗi | |
| if (this._stuckWatchdog) clearTimeout(this._stuckWatchdog); | |
| this._stuckWatchdog = setTimeout(() => { | |
| // Chỉ kích hoạt nếu loader vẫn còn hiện và progress ≤ 22% | |
| const _loader = this.container?.querySelector('#txa-loader'); | |
| if (!_loader || _loader.classList.contains('hidden')) return; | |
| const _pct = this.container?.querySelector('#txa-load-pct'); | |
| const currentPct = parseInt(_pct?.textContent || '0'); | |
| if (currentPct <= 22 && !isNaN(currentPct)) { | |
| console.warn('[TXAPlayer] Watchdog: loader stuck at', currentPct + '% after 18s'); | |
| this._txaShowHlsError?.('Quá trình tải mất quá nhiều thời gian. Có thể nguồn phim đang bảo trì.', null); | |
| } | |
| }, 18000); | |
| // Clear watchdog khi video thực sự play được | |
| this.video.addEventListener('playing', () => { | |
| if (this._stuckWatchdog) { clearTimeout(this._stuckWatchdog); this._stuckWatchdog = null; } | |
| }, { once: true }); | |
| // v6.5.0 - Auto-detect preview thumbnail sprite | |
| if (!this._isPreviewDetecting) { | |
| this._isPreviewDetecting = true; | |
| this._detectPreviewSprite(); | |
| } | |
| } | |
| /** | |
| * v6.5.2 - Auto-detect and preload preview sprite (preview.jpg) | |
| * OR New Turbo Storyboard (L1_M0.jpg) | |
| */ | |
| _detectPreviewSprite() { | |
| let previewUrl = this.options.previewUrl; | |
| const videoUrl = this.options.videoUrl; | |
| const thumbEl = this.container?.querySelector('#txa-preview-thumb'); | |
| if (!thumbEl) return; | |
| // Reset state | |
| this._useStoryboard = false; | |
| this._currentStoryboardLevel = 'L1'; | |
| // Try auto-detection of base path | |
| let basePath = ''; | |
| let actualVideoUrl = videoUrl || ''; | |
| // v6.6.3 - Support proxied URLs by extracting the real target | |
| if (actualVideoUrl.includes('/api/proxy-media?url=')) { | |
| try { | |
| const search = actualVideoUrl.split('?')[1]; | |
| const params = new URLSearchParams(search); | |
| actualVideoUrl = params.get('url') || actualVideoUrl; | |
| } catch(e) {} | |
| } | |
| if (actualVideoUrl) { | |
| try { | |
| const url = new URL(actualVideoUrl); | |
| const m3u8Match = actualVideoUrl.match(/(https?:\/\/[^/]*(?:s3|tebi|storage|cdn)[^/]*\/[^?]*\/)/); | |
| if (m3u8Match) { | |
| basePath = m3u8Match[1]; | |
| } else { | |
| const pathParts = url.pathname.split('/'); | |
| pathParts.pop(); | |
| basePath = url.origin + pathParts.join('/') + '/'; | |
| } | |
| } catch (e) { | |
| const lastSlash = actualVideoUrl.lastIndexOf('/'); | |
| if (lastSlash > 0) basePath = actualVideoUrl.substring(0, lastSlash + 1); | |
| } | |
| } | |
| const handleFinalError = () => { | |
| this._previewSpriteUrl = null; | |
| this._previewSpriteLoaded = false; | |
| thumbEl.classList.remove('loading', 'has-sprite'); | |
| thumbEl.style.backgroundImage = 'none'; | |
| }; | |
| // 🚀 STRATEGY 1: Check for Turbo Storyboard (VOD Pipeline v2) | |
| if (basePath) { | |
| const storyboardPath = basePath + 'storyboard/'; | |
| const checkImg = new Image(); | |
| checkImg.src = this.getProxiedUrl(storyboardPath + 'L1_M0.jpg'); | |
| checkImg.onload = () => { | |
| if (this.destroyed) return; | |
| this._useStoryboard = true; | |
| this._storyboardPath = storyboardPath; | |
| this._previewSpriteLoaded = true; | |
| thumbEl.classList.add('has-sprite'); | |
| thumbEl.classList.remove('loading'); | |
| console.log('%c[TXAPlayer] 🚀 Turbo Storyboard Engine Active (L1) ✅', 'color:#a855f7; font-weight:bold;'); | |
| // Level Upgrade Timer (Premium Strategy) | |
| this._previewUpgradeTimer = setTimeout(() => { | |
| if (this.destroyed) return; | |
| if (window.innerWidth > 1200) this._currentStoryboardLevel = 'L3'; | |
| else if (window.innerWidth > 768) this._currentStoryboardLevel = 'L2'; | |
| console.log(`%c[TXAPlayer] 🖼️ Storyboard upgraded to ${this._currentStoryboardLevel} for crispness`, 'color:#a855f7;'); | |
| }, 4000); | |
| }; | |
| checkImg.onerror = () => { | |
| // FALLBACK to Strategy 2 (Legacy preview.jpg) | |
| this._detectLegacyPreview(previewUrl, basePath, thumbEl, handleFinalError); | |
| }; | |
| // 🚀 STRATEGY 3: Detect MP4 Multi-Quality (NEW v6.5.2) | |
| if (videoUrl && videoUrl.toLowerCase().endsWith('.mp4')) { | |
| const filename = videoUrl.split('/').pop(); | |
| if (filename.match(/(\d+)p\.mp4/i)) { | |
| this._detectMP4Qualities(basePath, filename); | |
| } | |
| } | |
| return; | |
| } | |
| this._detectLegacyPreview(previewUrl, basePath, thumbEl, handleFinalError); | |
| } | |
| /** | |
| * v6.5.2 - Predictive MP4 Multi-Quality Detection | |
| * Probes tebi/s3 folder for 1080p.mp4, 720p.mp4, etc. | |
| */ | |
| async _detectMP4Qualities(basePath, currentFilename) { | |
| const qualities = ['1080p', '720p', '480p', '360p', '144p']; | |
| const results = []; | |
| const currentLabel = currentFilename.replace('.mp4', '').toLowerCase(); | |
| for (const q of qualities) { | |
| if (q === currentLabel) { | |
| results.push({ height: parseInt(q), url: this.options.videoUrl, name: q.toUpperCase() }); | |
| continue; | |
| } | |
| const checkUrl = basePath + q + '.mp4'; | |
| try { | |
| // Fast check with HEAD request | |
| const res = await fetch(checkUrl, { method: 'HEAD' }); | |
| if (res.ok) { | |
| results.push({ height: parseInt(q), url: checkUrl, name: q.toUpperCase() }); | |
| } | |
| } catch (e) { } | |
| } | |
| if (results.length > 1) { | |
| this._mp4Levels = results.sort((a, b) => b.height - a.height); | |
| this.generateQualityList(); | |
| console.log(`%c[TXAPlayer] 🎞️ Predictive MP4 Active: ${this._mp4Levels.length} qualities found`, 'color:#a855f7; font-weight:bold;'); | |
| } | |
| } | |
| /** | |
| * v6.5.2 - Legacy Sprite Logic (Backward Compatibility) | |
| */ | |
| _detectLegacyPreview(previewUrl, basePath, thumbEl, handleFinalError) { | |
| if (!previewUrl && basePath) { | |
| previewUrl = this.getProxiedUrl(basePath + 'preview.jpg'); | |
| } | |
| if (!previewUrl) return; | |
| const loadPreview = (useCORS = true) => { | |
| const img = new Image(); | |
| if (useCORS) img.crossOrigin = 'anonymous'; | |
| img.onload = () => { | |
| if (this.destroyed) return; | |
| if (img.naturalWidth < 160 || img.naturalHeight < 90) { | |
| handleFinalError(); | |
| return; | |
| } | |
| if (useCORS) { | |
| if (this.options.previewLowUrl) { | |
| this._previewLowUrl = this.options.previewLowUrl; | |
| this._previewLowLoaded = true; | |
| thumbEl.style.backgroundImage = `url('${this._previewLowUrl}')`; | |
| thumbEl.classList.add('has-sprite'); | |
| thumbEl.classList.remove('loading'); | |
| this._previewUpgradeTimer = setTimeout(() => { | |
| if (this.destroyed) return; | |
| this._previewSpriteUrl = previewUrl; | |
| this._previewSpriteLoaded = true; | |
| thumbEl.style.backgroundImage = `url('${previewUrl}')`; | |
| thumbEl.style.backgroundSize = '1600px 900px'; | |
| }, 500); | |
| } else { | |
| // Canvas downscale | |
| try { | |
| const canvas = document.createElement('canvas'); | |
| const scale = 0.25; | |
| canvas.width = Math.round(img.naturalWidth * scale); | |
| canvas.height = Math.round(img.naturalHeight * scale); | |
| const ctx = canvas.getContext('2d'); | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'low'; | |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); | |
| this._previewLowUrl = canvas.toDataURL('image/jpeg', 0.5); | |
| this._previewLowLoaded = true; | |
| thumbEl.style.backgroundImage = `url('${this._previewLowUrl}')`; | |
| thumbEl.style.backgroundSize = '400px 225px'; | |
| thumbEl.classList.add('has-sprite'); | |
| thumbEl.classList.remove('loading'); | |
| } catch (e) { | |
| useHQDirectly(); | |
| return; | |
| } | |
| this._previewUpgradeTimer = setTimeout(() => { | |
| if (this.destroyed) return; | |
| this._previewSpriteUrl = previewUrl; | |
| this._previewSpriteLoaded = true; | |
| thumbEl.style.backgroundImage = `url('${previewUrl}')`; | |
| thumbEl.style.backgroundSize = '1600px 900px'; | |
| }, 600); | |
| } | |
| } else { | |
| useHQDirectly(); | |
| } | |
| }; | |
| const useHQDirectly = () => { | |
| this._previewSpriteUrl = previewUrl; | |
| this._previewSpriteLoaded = true; | |
| thumbEl.style.backgroundImage = `url('${previewUrl}')`; | |
| thumbEl.style.backgroundSize = '1600px 900px'; | |
| thumbEl.classList.add('has-sprite'); | |
| thumbEl.classList.remove('loading'); | |
| }; | |
| img.onerror = () => { | |
| if (useCORS) loadPreview(false); | |
| else handleFinalError(); | |
| }; | |
| img.src = previewUrl; | |
| }; | |
| loadPreview(true); | |
| } | |
| /** | |
| * v6.5.2 - Update preview thumbnail position based on hover/scrub position | |
| * Supports both Legacy Sprite and Neon Core Storyboard (L1/L2/L3) | |
| */ | |
| _updatePreviewThumb(pos) { | |
| const thumbEl = this.container?.querySelector('#txa-preview-thumb'); | |
| if (!thumbEl || !this._previewSpriteLoaded) return; | |
| const duration = this.video?.duration; | |
| if (!duration || !isFinite(duration)) return; | |
| const targetTime = pos * duration; | |
| let thumbW = 160, thumbH = 90; | |
| if (this._useStoryboard) { | |
| const config = STORYBOARD_LEVELS[this._currentStoryboardLevel]; | |
| const framesPerChunk = config.grid[0] * config.grid[1]; | |
| const frameIndex = Math.floor(targetTime / config.interval); | |
| const chunkIndex = Math.floor(frameIndex / framesPerChunk); | |
| const frameInChunk = frameIndex % framesPerChunk; | |
| const rawChunkUrl = `${this._storyboardPath}${config.prefix}${chunkIndex}.jpg`; | |
| const chunkUrl = this.getProxiedUrl(rawChunkUrl); | |
| const col = frameInChunk % config.grid[0]; | |
| const row = Math.floor(frameInChunk / config.grid[0]); | |
| thumbW = config.size[0]; | |
| thumbH = config.size[1]; | |
| thumbEl.style.backgroundImage = `url('${chunkUrl}')`; | |
| thumbEl.style.backgroundSize = `${config.grid[0] * thumbW}px ${config.grid[1] * thumbH}px`; | |
| thumbEl.style.backgroundPosition = `${-(col * thumbW)}px ${-(row * thumbH)}px`; | |
| thumbEl.style.width = thumbW + 'px'; | |
| thumbEl.style.height = thumbH + 'px'; | |
| } else { | |
| // Legacy Logic | |
| const frameIndex = Math.min(99, Math.floor(targetTime / 10)); | |
| const col = frameIndex % 10; | |
| const row = Math.floor(frameIndex / 10); | |
| thumbEl.style.backgroundPosition = `${-(col * 160)}px ${-(row * 90)}px`; | |
| thumbEl.style.width = '160px'; | |
| thumbEl.style.height = '90px'; | |
| } | |
| // Clamp to container bounds | |
| const pWrap = this.container?.querySelector('#txa-progress'); | |
| if (pWrap) { | |
| const wrapW = pWrap.offsetWidth; | |
| const half = thumbW / 2; | |
| const leftPx = pos * wrapW; | |
| if (leftPx < half) { | |
| thumbEl.style.left = half + 'px'; | |
| thumbEl.style.transform = 'translateX(-50%)'; | |
| } else if (leftPx > wrapW - half) { | |
| thumbEl.style.left = (wrapW - half) + 'px'; | |
| thumbEl.style.transform = 'translateX(-50%)'; | |
| } else { | |
| thumbEl.style.left = (pos * 100) + '%'; | |
| thumbEl.style.transform = 'translateX(-50%)'; | |
| } | |
| } | |
| } | |
| generateQualityList() { | |
| const list = this.container.querySelector('#txa-list-quality'); | |
| if (!list) return; | |
| list.innerHTML = ''; | |
| let levels = []; | |
| let currentLevel = -1; | |
| if (this.hls) { | |
| levels = this.hls.levels; | |
| currentLevel = this.hls.currentLevel; | |
| } else if (this.dashPlayer) { | |
| levels = this.dashPlayer.getBitrateInfoListFor('video'); | |
| currentLevel = this.dashPlayer.getQualityFor('video'); | |
| } else if (this._mp4Levels.length > 0) { | |
| levels = this._mp4Levels.map((l, i) => ({ ...l, originalIndex: i })); | |
| // Find current active level by URL | |
| currentLevel = this._mp4Levels.findIndex(l => l.url === (this._lastMP4Url || this.options.videoUrl)); | |
| if (currentLevel !== -1) currentLevel = levels[currentLevel].originalIndex; // Map to originalIndex | |
| } | |
| // Parse whitelist / manual quality list | |
| const maxQRaw = this.options.maxQuality || 'auto'; | |
| const qList = maxQRaw.split(',').map(s => s.trim().toLowerCase()); | |
| const isAutoAllowed = qList.includes('auto'); | |
| // Allow single manual quality (e.g. "1080p") to show menu if provided | |
| const hasManualQualities = qList.length > 0 && !(qList.length === 1 && qList[0] === 'auto'); | |
| const hasMultipleLevels = levels && levels.length > 1; | |
| if (!hasMultipleLevels && !hasManualQualities) { | |
| this.container.querySelector('#txa-item-quality').style.display = 'none'; | |
| return; | |
| } | |
| this.container.querySelector('#txa-item-quality').style.display = 'flex'; | |
| // Filter levels for UI | |
| const isWhitelisted = (lvl) => { | |
| if (qList.length === 0 || (qList.length === 1 && qList[0] === 'auto')) return true; | |
| const h = lvl.height || 0; | |
| const label = h + 'p'; | |
| return qList.some(q => { | |
| if (q === label) return true; // Exact match: "360p" === "360p" | |
| if (q === '4k' && h >= 2160) return true; | |
| if (q === '2k' && h >= 1440) return true; | |
| if (q === 'fullhd' && h >= 1080) return true; | |
| if (q === 'fhd' && h >= 1080) return true; | |
| if (q === 'hd' && h >= 720 && h < 1080) return true; // HD = 720p only | |
| if (['cam', 'sd'].includes(q)) return true; | |
| return false; | |
| }); | |
| }; | |
| const availableLevels = levels.map((l, i) => ({ ...l, originalIndex: i })); | |
| const filteredLevels = availableLevels.filter(isWhitelisted); | |
| let uiLevels = filteredLevels.length > 0 ? filteredLevels : availableLevels; | |
| // Dummy Logic if no levels found but Manual Quality is Enforced (e.g. Single MP4 with label) | |
| if (uiLevels.length === 0 && hasManualQualities && (!levels || levels.length === 0)) { | |
| uiLevels = qList.filter(q => q !== 'auto').map((q, i) => ({ | |
| height: parseInt(q.replace(/\D/g, '')) || 0, | |
| name: q.toUpperCase(), | |
| originalIndex: -100 - i // Dummy Index | |
| })); | |
| // Force first one active if no HLS | |
| if (currentLevel === -1 && uiLevels.length > 0) currentLevel = uiLevels[0].originalIndex; | |
| } | |
| // Special check: if we are using MP4 Predictive levels, levels are ALREADY uiLevels | |
| if (this._mp4Levels.length > 0 && uiLevels.length === 0) { | |
| uiLevels = levels; | |
| } | |
| // Auto Option (Only if we have real HLS/DASH switching capability) | |
| if (isAutoAllowed && (hasMultipleLevels || levels.length > 0)) { | |
| const autoItem = document.createElement('div'); | |
| autoItem.className = 'txa-menu-item' + (currentLevel === -1 ? ' active' : ''); | |
| autoItem.dataset.quality = -1; | |
| let autoLabel = 'Auto'; | |
| if (uiLevels.length < levels.length && levels.length > 0) { | |
| const sorted = [...uiLevels].sort((a, b) => a.height - b.height); | |
| const minH = sorted[0]?.height || '?'; | |
| const maxH = sorted[sorted.length - 1]?.height || '?'; | |
| autoLabel = `Auto (${minH}p — ${maxH}p)`; | |
| } | |
| autoItem.innerHTML = `<span>${autoLabel}</span><span id="txa-auto-label"></span>`; | |
| if (this.options.isGuest) { | |
| autoItem.classList.add('disabled'); | |
| autoItem.onclick = (e) => { e.stopPropagation(); this.showToast('Vui lòng đăng nhập để chỉnh chất lượng', 'warning'); }; | |
| } else { | |
| autoItem.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.setQuality(-1); | |
| this.updateQualityUI(-1); | |
| }; | |
| } | |
| list.appendChild(autoItem); | |
| } | |
| // Sort levels high to low for UI | |
| const sortedUiLevels = [...uiLevels].sort((a, b) => b.height - a.height); | |
| sortedUiLevels.forEach((level) => { | |
| const item = document.createElement('div'); | |
| item.className = 'txa-menu-item' + (currentLevel === level.originalIndex ? ' active' : ''); | |
| item.dataset.quality = level.originalIndex; // Ensure dataset is set | |
| if (this.options.isGuest) item.classList.add('disabled'); | |
| let label = ''; | |
| // Priority 1: Resolution based | |
| if (level.height && level.height > 0) { | |
| label = level.height + 'p'; | |
| if (level.height >= 2160) label = '2160p 4K'; | |
| else if (level.height >= 1440) label = '1440p 2K'; | |
| else if (level.height >= 1080) label = '1080p FHD'; | |
| else if (level.height >= 720) label = '720p HD'; | |
| } | |
| // Priority 2: Level Name from Manifest | |
| if ((!label || label === '0p') && level.name) { | |
| label = level.name; | |
| } | |
| // Priority 3: Inference from Config (Single Custom Label) | |
| if (!label || label === '0p') { | |
| const specialLabels = qList.filter(q => | |
| q !== 'auto' && !q.endsWith('p') && !['4k', '2k', 'fullhd', 'hd'].includes(q.toLowerCase()) | |
| ); | |
| if (specialLabels.length === 1) { | |
| label = specialLabels[0]; // Keep original case | |
| } else if (specialLabels.length > 1) { | |
| label = level.bitrate ? `SD (${(level.bitrate / 1000).toFixed(0)}k)` : `Quality ${level.originalIndex}`; | |
| } else { | |
| label = level.bitrate ? `SD (${(level.bitrate / 1000).toFixed(0)}k)` : 'Original'; | |
| } | |
| } | |
| const bitrate = level.bitrate ? ` ${(level.bitrate / 1000000).toFixed(1)} Mbps` : ''; | |
| const isPremiumQuality = level.height >= 1080; | |
| if (this.options.isGuest && isPremiumQuality) { | |
| item.innerHTML = `<span>${label}</span><span style="background:#facc15;color:#000;font-size:8px;padding:2px 4px;border-radius:4px;font-weight:900;margin-left:8px;">PRO</span>`; | |
| item.style.opacity = '0.7'; | |
| item.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.showToast('Vui lòng đăng nhập để xem chất lượng ' + label, 'warning'); | |
| }; | |
| } else { | |
| item.innerHTML = `<span>${label}</span><span style="opacity:0.5;font-size:11px;">${bitrate}</span>`; | |
| item.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.setQuality(level.originalIndex); | |
| this.updateQualityUI(level.originalIndex); | |
| // For dummy levels (MP4), give immediate feedback as setQuality HLS logic won't run | |
| if (level.originalIndex <= -100) { | |
| this.showFeedback('text', `Quality: ${label}`); | |
| } | |
| }; | |
| } | |
| list.appendChild(item); | |
| }); | |
| // Ensure "Auto" is the default selection if currentLevel is -1 | |
| this.updateQualityUI(currentLevel); | |
| // Apply Default Quality Logic (Moved inside function) | |
| let targetLevel = null; | |
| const savedQ = localStorage.getItem('txa-quality'); | |
| // Check against `uiLevels` (variable from above scope) | |
| if (savedQ !== null && !this.options.isGuest) { | |
| const parsed = parseInt(savedQ); | |
| const isStillAllowed = parsed === -1 || uiLevels.some(l => l.originalIndex === parsed); | |
| if (isStillAllowed) { | |
| targetLevel = parsed; | |
| } else { | |
| targetLevel = -1; | |
| localStorage.removeItem('txa-quality'); | |
| } | |
| } else if (this.options.maxQuality && !this.options.isGuest) { | |
| targetLevel = -1; | |
| } | |
| if (targetLevel !== null && targetLevel !== undefined) { | |
| this.setQuality(targetLevel); | |
| this.updateQualityUI(targetLevel); | |
| } | |
| if (uiLevels.length < levels.length) { | |
| this.enforceQualityRestriction(availableLevels, uiLevels); | |
| } | |
| } | |
| /** | |
| * HARD QUALITY ENFORCEMENT | |
| * Physically removes disallowed levels from HLS.js manifest | |
| * and sets autoLevelCapping + LEVEL_SWITCHING interceptor as fallback. | |
| * This ensures ABR can NEVER select a quality outside admin's config. | |
| */ | |
| enforceQualityRestriction(allLevels, allowedLevels) { | |
| const allowedIndices = new Set(allowedLevels.map(l => l.originalIndex)); | |
| if (this.hls) { | |
| // Strategy 1: Remove levels from HLS.js (v1.4+) | |
| // We must remove from highest index to lowest to avoid index shifting | |
| const toRemove = allLevels | |
| .filter(l => !allowedIndices.has(l.originalIndex)) | |
| .sort((a, b) => b.originalIndex - a.originalIndex); | |
| if (typeof this.hls.removeLevel === 'function') { | |
| // Build index mapping: old index → new index after removals | |
| this._qualityIndexMap = {}; | |
| let newIdx = 0; | |
| for (const lvl of allLevels.sort((a, b) => a.originalIndex - b.originalIndex)) { | |
| if (allowedIndices.has(lvl.originalIndex)) { | |
| this._qualityIndexMap[lvl.originalIndex] = newIdx; | |
| newIdx++; | |
| } | |
| } | |
| // Actually remove the levels (reverse order to keep indices stable) | |
| toRemove.forEach(lvl => { | |
| try { | |
| this.hls.removeLevel(lvl.originalIndex); | |
| } catch (e) { | |
| // Silently fail if removeLevel not available on this version | |
| } | |
| }); | |
| // After removal: cap auto to available levels | |
| const newMaxIdx = this.hls.levels.length - 1; | |
| if (newMaxIdx >= 0) { | |
| this.hls.autoLevelCapping = newMaxIdx; | |
| } | |
| // Remap onclick handlers for quality items | |
| const qualityItems = this.container.querySelectorAll('#txa-list-quality .txa-menu-item[data-quality]'); | |
| // Items' data-quality may refer to old indices which no longer exist | |
| } else { | |
| // Strategy 2: Fallback for older HLS.js without removeLevel | |
| // Use autoLevelCapping for max, and LEVEL_SWITCHING to block disallowed | |
| const maxAllowed = Math.max(...allowedIndices); | |
| this.hls.autoLevelCapping = maxAllowed; | |
| // Intercept level switching to block non-allowed levels | |
| this.hls.on(Hls.Events.LEVEL_SWITCHING, (event, data) => { | |
| if (!allowedIndices.has(data.level)) { | |
| // Force back to nearest allowed level | |
| const nearest = [...allowedIndices].sort((a, b) => | |
| Math.abs(a - data.level) - Math.abs(b - data.level) | |
| )[0]; | |
| if (nearest !== undefined) { | |
| this.hls.currentLevel = nearest; | |
| } | |
| } | |
| }); | |
| } | |
| } else if (this.dashPlayer) { | |
| // DashJS: Use maxBitrate and minBitrate constraints | |
| const allowedBitrates = allowedLevels.map(l => l.bitrate).filter(Boolean); | |
| if (allowedBitrates.length > 0) { | |
| const maxBr = Math.max(...allowedBitrates); | |
| const minBr = Math.min(...allowedBitrates); | |
| this.dashPlayer.updateSettings({ | |
| 'streaming': { | |
| 'abr': { | |
| 'maxBitrate': { 'video': maxBr }, | |
| 'minBitrate': { 'video': minBr } | |
| } | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| setQuality(levelIndex) { | |
| if (this.options.isGuest) return; | |
| // Show immediate loading indicator when switching quality | |
| if (this.showLoader) { | |
| this.showLoader(50); | |
| const loadText = this.container.querySelector('#txa-load-text'); | |
| if (loadText) loadText.textContent = 'Đang chuyển đổi chất lượng...'; | |
| } | |
| if (this.hls) { | |
| // Check if we need to force a redraw or buffer clear | |
| const wasPlaying = !this.video.paused; | |
| this.hls.currentLevel = levelIndex; | |
| // Fix for "stuck/frozen" frames on some browsers: | |
| // Briefly pause and play to force-refresh the decoder if it was playing | |
| if (wasPlaying) { | |
| setTimeout(() => { | |
| if (this.video.paused) this.video.play().catch(() => { }); | |
| }, 100); | |
| } | |
| } else if (this.dashPlayer) { | |
| const wasPlaying = !this.video.paused; | |
| if (levelIndex === -1) { | |
| this.dashPlayer.updateSettings({ 'streaming': { 'abr': { 'autoSwitchBitrate': { 'video': true } } } }); | |
| } else { | |
| this.dashPlayer.updateSettings({ 'streaming': { 'abr': { 'autoSwitchBitrate': { 'video': false } } } }); | |
| this.dashPlayer.setQualityFor('video', levelIndex); | |
| } | |
| if (wasPlaying) { | |
| setTimeout(() => { | |
| if (this.video.paused) this.video.play().catch(() => { }); | |
| }, 100); | |
| } | |
| // For DASH, hide loader after a short delay or logic (simplification) | |
| setTimeout(() => this.hideLoader && this.hideLoader(), 1500); | |
| } else if (this._mp4Levels.length > 0 && levelIndex >= 0) { | |
| // v6.5.2 Switching between Direct MP4 files | |
| // Handle originalIndex mapping back to our _mp4Levels | |
| const newLevel = this._mp4Levels.find(l => l.originalIndex === levelIndex) || this._mp4Levels[levelIndex]; | |
| if (newLevel && newLevel.url !== (this._lastMP4Url || this.options.videoUrl)) { | |
| const currentTime = this.video.currentTime; | |
| const wasPlaying = !this.video.paused; | |
| if (this.showLoader) this.showLoader(50); | |
| this._lastMP4Url = newLevel.url; | |
| this.video.src = newLevel.url; | |
| this.video.onloadedmetadata = () => { | |
| this.video.currentTime = currentTime; | |
| if (wasPlaying) this.video.play().catch(() => { }); | |
| this.hideLoader && this.hideLoader(); | |
| this.generateQualityList(); // Update UI active state | |
| this.video.onloadedmetadata = null; | |
| }; | |
| } | |
| } | |
| // Save preference | |
| localStorage.setItem('txa-quality', levelIndex); | |
| // Close settings menu | |
| this.navSettings('main'); | |
| } | |
| updateQualityUI(levelIndex) { | |
| const items = this.container.querySelectorAll('#txa-list-quality .txa-menu-item'); | |
| let activeLabel = 'Auto'; | |
| let shortLabel = 'Auto'; | |
| let realResolution = ''; // New: Track actual playing resolution | |
| // 1. Detect Real Resolution used by Auto (-1) | |
| if (levelIndex === -1 && this.hls) { | |
| // HLS: Use currentLevel (playing) or loadLevel (loading) | |
| const lvlIdx = this.hls.currentLevel !== -1 ? this.hls.currentLevel : this.hls.loadLevel; | |
| if (lvlIdx !== -1 && this.hls.levels && this.hls.levels[lvlIdx]) { | |
| const lvl = this.hls.levels[lvlIdx]; | |
| if (lvl && lvl.height) realResolution = lvl.height; | |
| } | |
| } else if (levelIndex === -1 && this.dashPlayer) { | |
| // DASH: Logic if needed | |
| } | |
| items.forEach(item => { | |
| item.classList.remove('active'); | |
| const icon = item.querySelector('.fa-check'); | |
| if (icon) icon.remove(); | |
| if (parseInt(item.dataset.quality) === levelIndex) { | |
| item.classList.add('active'); | |
| const check = document.createElement('i'); | |
| check.className = 'fas fa-check'; | |
| check.style.marginLeft = 'auto'; | |
| check.style.color = 'var(--txa-brand)'; | |
| check.style.fontSize = '12px'; | |
| item.appendChild(check); | |
| const textSpan = item.querySelector('span'); | |
| if (textSpan) { | |
| activeLabel = textSpan.textContent; | |
| // Generate Short Label for Badge | |
| if (levelIndex === -1) { | |
| shortLabel = 'Auto'; | |
| // Keep pure Auto for badge text to avoid clutter, | |
| // but we will use realResolution for color coding below | |
| } else { | |
| shortLabel = activeLabel | |
| .replace(' UHD', '') | |
| .replace(' QHD', '') | |
| .replace(' FHD', '') | |
| .replace(' HD', '') | |
| .replace('p', ''); | |
| } | |
| } | |
| } | |
| }); | |
| // Update Parent Menu Text | |
| const lbl = this.container.querySelector('#txa-lbl-quality'); | |
| if (lbl) { | |
| // If Auto, append real resolution dynamically | |
| if (levelIndex === -1 && realResolution) { | |
| lbl.textContent = `Auto (${realResolution}p)`; | |
| } else { | |
| lbl.textContent = activeLabel; | |
| } | |
| } | |
| // Update Settings Button Badge | |
| const badge = this.container.querySelector('#txa-quality-badge'); | |
| if (badge) { | |
| // If Auto, show actual res if we have it? Or just 'Auto'? | |
| // "SHOW CURENT QUALITY ON BAGE ICON SETTINGS" -> User wants to see resolution | |
| if (levelIndex === -1 && realResolution) { | |
| badge.textContent = `${realResolution}p`; // e.g. "720p" | |
| } else { | |
| badge.textContent = shortLabel; // "1080", "AUTO" | |
| } | |
| // Color Coding | |
| badge.style.background = 'rgba(255,255,255,0.2)'; | |
| badge.style.color = '#fff'; | |
| badge.style.fontWeight = 'normal'; | |
| badge.style.boxShadow = 'none'; | |
| // Determine effective resolution for styling | |
| let effectiveRes = realResolution ? parseInt(realResolution) : 0; | |
| if (levelIndex !== -1) effectiveRes = parseInt(shortLabel) || 0; | |
| if (shortLabel.includes('4K')) effectiveRes = 2160; | |
| if (effectiveRes >= 2160) { // 4K | |
| badge.style.background = '#f43f5e'; // Rose | |
| badge.style.boxShadow = '0 0 10px rgba(244, 63, 94, 0.4)'; | |
| badge.style.fontWeight = 'bold'; | |
| } else if (effectiveRes >= 1080) { // FHD | |
| badge.style.background = '#eab308'; // Yellow | |
| badge.style.color = '#000'; | |
| badge.style.fontWeight = '800'; | |
| } else if (effectiveRes >= 720) { // HD | |
| badge.style.background = '#3b82f6'; // Blue | |
| } else if (activeLabel === 'Auto' || levelIndex === -1) { | |
| // Low res auto or unknown | |
| badge.style.background = 'var(--txa-brand)'; | |
| } | |
| } | |
| // Toast Notification | |
| if (this._lastQualityIndex !== levelIndex) { | |
| if (this._lastQualityIndex !== undefined) { | |
| let msg = `Đã chuyển sang: ${activeLabel}`; | |
| if (levelIndex === -1 && realResolution) msg = `Tự động (${realResolution}p)`; | |
| this.showToast(msg, 'info'); | |
| } | |
| this._lastQualityIndex = levelIndex; | |
| } | |
| } | |
| renderMarkers() { | |
| if (!this.options.isLoggedIn) return; | |
| const container = this.container.querySelector('#txa-markers'); | |
| if (!container) return; | |
| // Always reset markers first, even if duration is not ready | |
| container.innerHTML = ''; | |
| if (!this.video || !this.video.duration || !this.options.markers) return; | |
| const dur = this.video.duration; | |
| const { intro, outro } = this.options.markers; | |
| if (intro && intro[1] > intro[0]) { | |
| const zone = document.createElement('div'); | |
| zone.className = 'txa-marker-zone intro'; | |
| zone.style.left = (intro[0] / dur * 100) + '%'; | |
| zone.style.width = ((intro[1] - intro[0]) / dur * 100) + '%'; | |
| container.appendChild(zone); | |
| const dotStart = document.createElement('div'); | |
| dotStart.className = 'txa-marker-dot intro-start'; | |
| dotStart.style.left = (intro[0] / dur * 100) + '%'; | |
| container.appendChild(dotStart); | |
| const dotEnd = document.createElement('div'); | |
| dotEnd.className = 'txa-marker-dot intro-end'; | |
| dotEnd.style.left = (intro[1] / dur * 100) + '%'; | |
| container.appendChild(dotEnd); | |
| } | |
| if (outro && (outro[1] > outro[0] || (outro[0] > 0 && (outro[1] === 0 || !outro[1])))) { | |
| const effectiveOutroOut = (outro[1] > 0) ? outro[1] : dur; | |
| const zone = document.createElement('div'); | |
| zone.className = 'txa-marker-zone outro'; | |
| zone.style.left = (outro[0] / dur * 100) + '%'; | |
| zone.style.width = ((effectiveOutroOut - outro[0]) / dur * 100) + '%'; | |
| container.appendChild(zone); | |
| const dotStart = document.createElement('div'); | |
| dotStart.className = 'txa-marker-dot outro-start'; | |
| dotStart.style.left = (outro[0] / dur * 100) + '%'; | |
| container.appendChild(dotStart); | |
| const dotEnd = document.createElement('div'); | |
| dotEnd.className = 'txa-marker-dot outro-end'; | |
| dotEnd.style.left = (effectiveOutroOut / dur * 100) + '%'; | |
| container.appendChild(dotEnd); | |
| } | |
| } | |
| initSubtitles() { | |
| const subs = this.options.subtitles || []; | |
| const hasSubs = subs.length > 0; | |
| // 1. Update Internal Toolbar Track support if needed | |
| // (Clearing old tracks first) | |
| // Remove existing track elements from video to prevent duplicates | |
| Array.from(this.video.children).filter(child => child.tagName === 'TRACK').forEach(track => track.remove()); | |
| subs.forEach((sub, index) => { | |
| const track = document.createElement('track'); | |
| track.kind = 'subtitles'; | |
| track.label = sub.label || sub.lang; | |
| track.srclang = sub.lang; | |
| // Handle both URL (src/file) and inline content | |
| if (sub.src || sub.file) { | |
| track.src = sub.src || sub.file; | |
| } else if (sub.content) { | |
| // Create blob URL for inline VTT content | |
| const blob = new Blob([sub.content], { type: 'text/vtt' }); | |
| track.src = URL.createObjectURL(blob); | |
| } | |
| this.video.appendChild(track); | |
| }); | |
| // 2. Update Settings Menu UI | |
| const list = this.container.querySelector('#txa-list-subs'); | |
| const itemSubs = this.container.querySelector('#txa-item-subs'); | |
| const btnSubs = this.container.querySelector('#txa-cc'); | |
| if (itemSubs) itemSubs.style.display = hasSubs ? 'flex' : 'none'; | |
| if (btnSubs) { | |
| btnSubs.style.display = hasSubs ? '' : 'none'; | |
| const icon = btnSubs.querySelector('i'); | |
| if (icon) { | |
| icon.style.opacity = hasSubs ? '1' : '0.4'; | |
| icon.style.filter = hasSubs ? 'none' : 'grayscale(1)'; | |
| } | |
| const tip = btnSubs.querySelector('.txa-tooltip'); | |
| if (tip) tip.textContent = hasSubs ? 'Subtitles (c)' : 'No Subtitles'; | |
| } | |
| if (list) { | |
| list.innerHTML = `<div class="txa-menu-item" data-sub="-1">Off</div>`; | |
| subs.forEach((sub, index) => { | |
| const item = document.createElement('div'); | |
| item.className = 'txa-menu-item'; | |
| item.dataset.sub = index; | |
| item.innerHTML = `<span>${sub.label || sub.lang}</span><span style="font-size:10px;opacity:0.5;">${sub.lang || ''}</span>`; | |
| item.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.toggleSub(index); | |
| }; | |
| list.appendChild(item); | |
| }); | |
| // Auto-load first subtitle if enabled or newly switched | |
| // If activeSubIdx is -1 (Off) and there are subs, default to first | |
| // If activeSubIdx is set, try to re-activate it | |
| if (hasSubs) { | |
| if (this.activeSubIdx === undefined || this.activeSubIdx === -1) { | |
| this.toggleSub(0); | |
| list.querySelector('[data-sub="0"]')?.classList.add('active'); | |
| } else { | |
| this.toggleSub(this.activeSubIdx); | |
| list.querySelector(`[data-sub="${this.activeSubIdx}"]`)?.classList.add('active'); | |
| } | |
| } else { | |
| this.toggleSub(-1); // Ensure subtitles are off if none are available | |
| list.querySelector('[data-sub="-1"]')?.classList.add('active'); | |
| } | |
| } | |
| } | |
| bindEvents() { | |
| const el = (s) => this.container.querySelector(s); | |
| // Play/Pause | |
| const toggle = () => this.togglePlay(); | |
| const playBtnMain = el('#txa-play'); | |
| if (playBtnMain) playBtnMain.onclick = toggle; | |
| // Prev/Next Navigation | |
| const prevBtn = el('#txa-prev'); | |
| const nextBtn = el('#txa-next'); | |
| if (prevBtn) prevBtn.onclick = (e) => { | |
| if (e) e.preventDefault(); | |
| if (this.options.prevEpisodeUrl && typeof window.switchEpisodeSeamless === 'function') { | |
| window.switchEpisodeSeamless(this.options.prevEpisodeUrl); | |
| } else if (this.options.prevEpisodeUrl) { | |
| window.location.href = this.options.prevEpisodeUrl; | |
| } | |
| }; | |
| if (nextBtn) nextBtn.onclick = (e) => { | |
| if (e) e.preventDefault(); | |
| if (this.options.nextEpisodeUrl && typeof window.switchEpisodeSeamless === 'function') { | |
| window.switchEpisodeSeamless(this.options.nextEpisodeUrl); | |
| } else if (this.options.nextEpisodeUrl) { | |
| window.location.href = this.options.nextEpisodeUrl; | |
| } | |
| }; | |
| if (this.video) this.video.onclick = toggle; | |
| const centerPlay = el('#txa-center-play'); | |
| if (centerPlay) centerPlay.onclick = toggle; | |
| const zoneL = el('#txa-zone-l'); | |
| if (zoneL) zoneL.onclick = (e) => { | |
| if (e.detail === 1) { | |
| this._clickTimer = setTimeout(() => toggle(), 250); | |
| } else if (e.detail > 1) { | |
| clearTimeout(this._clickTimer); | |
| } | |
| }; | |
| const zoneR = el('#txa-zone-r'); | |
| if (zoneR) zoneR.onclick = (e) => { | |
| if (e.detail === 1) { | |
| this._clickTimer = setTimeout(() => toggle(), 250); | |
| } else if (e.detail > 1) { | |
| clearTimeout(this._clickTimer); | |
| } | |
| }; | |
| const replayBtnMain = el('#txa-replay-btn'); | |
| if (replayBtnMain) replayBtnMain.onclick = () => { | |
| if (this.video) { | |
| this.video.currentTime = 0; | |
| this.video.play(); | |
| } | |
| const replayDiv = el('#txa-replay'); | |
| if (replayDiv) replayDiv.classList.remove('show'); | |
| }; | |
| this.video.addEventListener('play', () => { | |
| if (this.destroyed) return; | |
| const playBtn = el('#txa-play'); | |
| if (playBtn) playBtn.innerHTML = '<i class="fas fa-pause"></i><div class="txa-tooltip">Pause (k)</div>'; | |
| this.animateCenter('play'); | |
| const replayBtn = el('#txa-replay'); | |
| if (replayBtn) replayBtn.classList.remove('show'); | |
| if (this.wrapper) this.wrapper.classList.add('video-playing'); | |
| const cp = el('#txa-center-play'); | |
| if (cp) cp.classList.remove('show'); | |
| this.showUI(); // Auto hide after playing starts | |
| }); | |
| this.video.addEventListener('pause', () => { | |
| if (this.destroyed) return; | |
| const playBtn = el('#txa-play'); | |
| if (playBtn) playBtn.innerHTML = '<i class="fas fa-play"></i><div class="txa-tooltip">Play (k)</div>'; | |
| this.animateCenter('pause'); | |
| this.showUI(false); // Maintain UI while paused | |
| if (this.wrapper) this.wrapper.classList.remove('video-playing'); | |
| const cp = el('#txa-center-play'); | |
| if (cp) cp.classList.add('show'); | |
| }); | |
| // Video Error Handler | |
| this.video.addEventListener('error', () => { | |
| if (this.destroyed) return; | |
| const error = this.video.error; | |
| if (!error) return; | |
| // Report native video error to database | |
| this.reportErrorToServer('nativeVideoError', error.message || 'Lỗi phát video HTML5', { code: error.code }); | |
| let errorMsg = 'Lỗi không xác định'; | |
| let shouldRetry = false; | |
| switch (error.code) { | |
| case MediaError.MEDIA_ERR_ABORTED: | |
| errorMsg = 'Phát video bị hủy'; | |
| break; | |
| case MediaError.MEDIA_ERR_NETWORK: | |
| errorMsg = 'Lỗi mạng khi load video'; | |
| shouldRetry = true; | |
| break; | |
| case MediaError.MEDIA_ERR_DECODE: | |
| errorMsg = 'Lỗi giải mã video'; | |
| break; | |
| case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: | |
| errorMsg = 'Nguồn video không hỗ trợ'; | |
| break; | |
| } | |
| console.error('[TXAPlayer] Video error:', error.code, errorMsg); | |
| // Hide loader | |
| const loader = el('#txa-loader'); | |
| if (loader) loader.classList.add('hidden'); | |
| // Show error toast | |
| this.showToast(`❌ ${errorMsg}`); | |
| // Auto retry for network errors | |
| if (shouldRetry && !this._isRetryingVideo) { | |
| this._isRetryingVideo = true; | |
| setTimeout(() => { | |
| if (!this.destroyed && this.options.videoUrl) { | |
| console.log('[TXAPlayer] Retrying video load...'); | |
| this.showToast('🔄 Đang thử load lại video...'); | |
| // Reload video source | |
| this.video.src = this.options.videoUrl; | |
| this.video.load(); | |
| // Show loader again | |
| if (loader) loader.classList.remove('hidden'); | |
| setTimeout(() => { | |
| this._isRetryingVideo = false; | |
| }, 2000); | |
| } | |
| }, 1500); | |
| } | |
| }); | |
| this.video.addEventListener('ended', () => { | |
| if (this.destroyed) return; | |
| if (this.wrapper) this.wrapper.classList.remove('video-playing'); | |
| if (!this.video.loop) { | |
| // Check if auto-next is enabled and we have next episode | |
| if (this.settings.autoNextEpisode && this.options.nextEpisodeUrl && !this._isAutoNextTriggered) { | |
| // Show countdown overlay | |
| this._showAutoNextCountdown(); | |
| } else { | |
| // Show replay button only if auto-next is not active | |
| const replayBtn = el('#txa-replay'); | |
| if (replayBtn) replayBtn.classList.add('show'); | |
| if (this.wrapper) this.wrapper.classList.add('active-ui'); | |
| } | |
| } | |
| }); | |
| this.video.ondurationchange = () => { | |
| if (this.destroyed) return; | |
| this.renderMarkers(); | |
| }; | |
| const pWrap = el('#txa-progress'); | |
| let pendingSeekPct = -1; | |
| const updateScrubbingUI = (pos, isDragOrClick = false) => { | |
| const duration = this.video?.duration || 0; | |
| const targetTime = pos * duration; | |
| // Only update the actual progress bar and main time if we are DRAGGING or explicitly CLICKING | |
| if (isDragOrClick || this.isDragging) { | |
| const playedEl = el('#txa-played'); | |
| if (playedEl) playedEl.style.width = (pos * 100) + '%'; | |
| const timeEl = el('#txa-time'); | |
| if (timeEl) timeEl.textContent = this.formatTimeDisplay(targetTime); | |
| pendingSeekPct = pos; | |
| } | |
| // Tooltip and Preview ALWAYS follow on hover | |
| const tip = el('#txa-hover-time'); | |
| if (tip) { | |
| const needHours = duration >= 3600; | |
| tip.textContent = this.formatTime(targetTime, needHours); | |
| tip.style.left = (pos * 100) + '%'; | |
| } | |
| this._updatePreviewThumb(pos); | |
| }; | |
| const updateHoverTooltip = (e, isDragOrClick = false) => { | |
| if (!pWrap) return; | |
| const rect = pWrap.getBoundingClientRect(); | |
| const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); | |
| updateScrubbingUI(pos, isDragOrClick); | |
| return pos; | |
| }; | |
| pWrap.onmousemove = (e) => { | |
| updateHoverTooltip(e, false); // Just hover tooltip | |
| }; | |
| pWrap.onmousedown = (e) => { | |
| if (e.button !== 0) return; // Left click only | |
| this.isDragging = true; | |
| pWrap.classList.add('dragging'); | |
| updateHoverTooltip(e, true); | |
| }; | |
| // Desktop Long Press (2x Speed) | |
| let deskPressTimer = null; | |
| let isDeskLongPress = false; | |
| this.video.onmousedown = (e) => { | |
| if (this.detectMobile()) return; | |
| if (e.button !== 0) return; // Left click only | |
| deskPressTimer = setTimeout(() => { | |
| if (!this.video.paused) { | |
| isDeskLongPress = true; | |
| if (!this.originalSpeed) this.originalSpeed = this.video.playbackRate; | |
| this.video.playbackRate = 2.0; | |
| let speedOverlay = this.container.querySelector('.txa-speed-overlay'); | |
| if (!speedOverlay) { | |
| speedOverlay = document.createElement('div'); | |
| speedOverlay.className = 'txa-speed-overlay'; | |
| speedOverlay.innerHTML = '<i class="fas fa-forward"></i> 2x Tốc độ'; | |
| this.container.appendChild(speedOverlay); | |
| } | |
| this.wrapper.classList.add('hide-controls'); | |
| setTimeout(() => speedOverlay.classList.add('show'), 10); | |
| } | |
| }, 500); | |
| }; | |
| pWrap.onclick = (e) => { | |
| if (!this.isDragging) { | |
| const rect = pWrap.getBoundingClientRect(); | |
| const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); | |
| this.safeSeek(pos * this.video.duration); | |
| } | |
| }; | |
| // Improved Mobile Touch Interaction | |
| const handleTouchProgress = (e) => { | |
| if (e.touches && e.touches[0]) { | |
| const rect = pWrap.getBoundingClientRect(); | |
| const pos = Math.max(0, Math.min(1, (e.touches[0].clientX - rect.left) / rect.width)); | |
| updateScrubbingUI(pos); | |
| // Show UI to prevent controls hiding while dragging | |
| this.wrapper.classList.add('active-ui'); | |
| e.preventDefault(); // Stop browser back/scroll | |
| e.stopPropagation(); | |
| } | |
| }; | |
| pWrap.addEventListener('touchstart', (e) => { | |
| this.isDragging = true; | |
| pWrap.classList.add('dragging'); | |
| handleTouchProgress(e); | |
| }, { passive: false }); | |
| pWrap.addEventListener('touchmove', (e) => { | |
| if (this.isDragging) handleTouchProgress(e); | |
| }, { passive: false }); | |
| const endTouchProgress = (e) => { | |
| if (this.isDragging) { | |
| this.isDragging = false; | |
| pWrap.classList.remove('dragging'); | |
| if (pendingSeekPct !== -1) { | |
| this.safeSeek(pendingSeekPct * this.video.duration); | |
| pendingSeekPct = -1; | |
| } | |
| } | |
| }; | |
| pWrap.addEventListener('touchend', endTouchProgress); | |
| pWrap.addEventListener('touchcancel', endTouchProgress); | |
| this._handlers.winMouseMove = (e) => { | |
| if (this.isDragging) { | |
| const rect = pWrap.getBoundingClientRect(); | |
| const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); | |
| updateScrubbingUI(pos); | |
| } | |
| }; | |
| this._handlers.winMouseUp = () => { | |
| if (this.isDragging) { | |
| this.isDragging = false; | |
| pWrap.classList.remove('dragging'); | |
| if (pendingSeekPct !== -1) { | |
| this.safeSeek(pendingSeekPct * this.video.duration); | |
| pendingSeekPct = -1; | |
| } | |
| } | |
| if (deskPressTimer) clearTimeout(deskPressTimer); | |
| if (isDeskLongPress) { | |
| isDeskLongPress = false; | |
| this.video.playbackRate = this.originalSpeed || 1.0; | |
| const speedOverlay = this.container.querySelector('.txa-speed-overlay'); | |
| if (speedOverlay) speedOverlay.classList.remove('show'); | |
| this.wrapper.classList.remove('hide-controls'); | |
| } | |
| }; | |
| window.addEventListener('mousemove', this._handlers.winMouseMove); | |
| window.addEventListener('mouseup', this._handlers.winMouseUp); | |
| this.video.addEventListener('timeupdate', () => { | |
| if (this.destroyed) return; | |
| this.updateProgress(); | |
| }); | |
| this.video.addEventListener('play', () => this.showUI()); | |
| this.video.addEventListener('pause', () => this.showUI(false)); | |
| this.video.addEventListener('playing', () => this.showUI()); | |
| // v6.4.1 - Enhanced Mobile UI Handling | |
| if (this.detectMobile()) { | |
| // Remove redundant touchstart on video which might reset timer too often | |
| // Instead rely on handleMobileEvents wrapper listener | |
| // Listen for orientation/resize to update clock visibility immediately | |
| window.addEventListener('resize', () => this.updateClockVisibility()); | |
| window.addEventListener('orientationchange', () => { | |
| setTimeout(() => this.updateClockVisibility(), 200); | |
| }); | |
| } | |
| el('#txa-mute').onclick = () => { this.video.muted = !this.video.muted; this.updateVolUI(); this.saveSettings(); }; | |
| const volSlider = el('#txa-vol'); | |
| const volTooltip = el('#txa-vol-tooltip'); | |
| const showVolTooltip = () => volTooltip?.classList.add('visible'); | |
| const hideVolTooltip = () => { if (!this._isVolDragging) volTooltip?.classList.remove('visible'); }; | |
| volSlider.oninput = (e) => { | |
| this.video.volume = e.target.value; | |
| this.video.muted = false; | |
| this.updateVolUI(); | |
| showVolTooltip(); | |
| }; | |
| // v6.5.2 - Centralized volume/rate sync to always save state correctly | |
| this.video.addEventListener('volumechange', () => { | |
| this.updateVolUI(); | |
| this.saveSettings(); | |
| }); | |
| this.video.addEventListener('ratechange', () => { | |
| this.saveSettings(); | |
| }); | |
| volSlider.onmousedown = () => { this._isVolDragging = true; showVolTooltip(); }; | |
| volSlider.onmouseenter = showVolTooltip; | |
| volSlider.onmouseleave = hideVolTooltip; | |
| window.addEventListener('mouseup', () => { | |
| if (this._isVolDragging) { | |
| this._isVolDragging = false; | |
| hideVolTooltip(); | |
| } | |
| }); | |
| el('#txa-settings').onclick = (e) => { e.stopPropagation(); el('#txa-panel').classList.toggle('active'); this.navSettings('main'); }; | |
| el('#txa-fs').onclick = () => this.toggleFS(); | |
| el('#txa-pip').onclick = () => this.toggleNativePiP(); | |
| // TXA PRO Toggles | |
| const autoSkipBtn = el('#txa-auto-skip'); | |
| if (autoSkipBtn) autoSkipBtn.onclick = (e) => { e.stopPropagation(); this.toggleAutoSkipIntro(); }; | |
| const autoNextBtn = el('#txa-auto-next'); | |
| if (autoNextBtn) autoNextBtn.onclick = (e) => { e.stopPropagation(); this.toggleAutoNextEpisode(); }; | |
| // DETECTION PIP/FS TO UPDATE UI | |
| this.video.onenterpictureinpicture = () => { | |
| const pipBtn = el('#txa-pip'); // Added null check | |
| if (pipBtn) pipBtn.innerHTML = '<i class="fas fa-window-restore"></i><div class="txa-tooltip">Exit Mini Player (p)</div>'; | |
| }; | |
| this.video.onleavepictureinpicture = () => { | |
| const pipBtn = el('#txa-pip'); // Added null check | |
| if (pipBtn) pipBtn.innerHTML = '<i class="fas fa-clone"></i><div class="txa-tooltip">Mini Player (p)</div>'; | |
| }; | |
| this._handlers.docFSChange = () => { | |
| const fsBtn = el('#txa-fs'); | |
| if (document.fullscreenElement) { | |
| this.wrapper.classList.add('fullscreen-mode'); | |
| if (fsBtn) fsBtn.innerHTML = '<i class="fas fa-compress"></i><div class="txa-tooltip">Exit Fullscreen (f)</div>'; | |
| } else { | |
| this.wrapper.classList.remove('fullscreen-mode'); | |
| if (fsBtn) fsBtn.innerHTML = '<i class="fas fa-expand"></i><div class="txa-tooltip">Fullscreen (f)</div>'; | |
| } | |
| this.showUI(); // Trigger timer on FS change | |
| this.updateClockVisibility(); | |
| }; | |
| document.addEventListener('fullscreenchange', this._handlers.docFSChange); | |
| el('#txa-nc-cancel').onclick = (e) => { | |
| e.stopPropagation(); | |
| // Clear countdown interval if exists | |
| if (this._autoNextCountdownInterval) { | |
| clearInterval(this._autoNextCountdownInterval); | |
| this._autoNextCountdownInterval = null; | |
| } | |
| this._isAutoNextTriggered = true; // Block auto-trigger for this episode | |
| el('#txa-next-countdown').classList.remove('show'); | |
| if (this.wrapper) this.wrapper.classList.remove('has-countdown'); | |
| this.showToast('Đã hủy tự động chuyển tập'); | |
| // Change "Skip Outro" button text if next episode exists | |
| const skipOutro = this.container.querySelector('#txa-skip-outro'); | |
| if (skipOutro && this.options.nextEpisodeUrl) { | |
| skipOutro.innerHTML = '<i class="fas fa-step-forward"></i> Tập Tiếp'; | |
| skipOutro.style.display = 'block'; // Ensure it's visible now | |
| } | |
| }; | |
| el('#txa-nc-next').onclick = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Clear countdown interval if exists | |
| if (this._autoNextCountdownInterval) { | |
| clearInterval(this._autoNextCountdownInterval); | |
| this._autoNextCountdownInterval = null; | |
| } | |
| this._isAutoNextTriggered = true; | |
| el('#txa-next-countdown').classList.remove('show'); | |
| if (this.wrapper) this.wrapper.classList.remove('has-countdown'); | |
| this._triggerAutoNext(); | |
| }; | |
| el('#txa-skip-intro').onclick = () => this.skipIntro(); | |
| el('#txa-skip-outro').onclick = () => this.skipOutro(); | |
| this.container.querySelectorAll('[data-goto]').forEach(i => i.onclick = (e) => { e.stopPropagation(); this.navSettings(i.dataset.goto); }); | |
| this.container.querySelectorAll('[data-back]').forEach(i => i.onclick = (e) => { e.stopPropagation(); this.navSettings(i.dataset.back); }); | |
| // Speed Menu Clicks | |
| this.container.querySelectorAll('[data-speed]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.setSpeed(parseFloat(i.dataset.speed)); | |
| this.container.querySelectorAll('[data-speed]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| }); | |
| // Ratio Menu Clicks | |
| this.container.querySelectorAll('[data-ratio]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.setAspectRatio(i.dataset.ratio); | |
| this.container.querySelectorAll('[data-ratio]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| }); | |
| // Flip Menu Clicks | |
| this.container.querySelectorAll('[data-flip]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.setVideoFlip(i.dataset.flip); | |
| this.container.querySelectorAll('[data-flip]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| }); | |
| // Subtitle Size Menu | |
| this.container.querySelectorAll('[data-subsize]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| const cap = el('#txa-captions'); | |
| if (cap) { | |
| cap.classList.remove('size-mini', 'size-small', 'size-medium', 'size-large', 'size-jumbo'); | |
| cap.classList.add('size-' + i.dataset.subsize); | |
| } | |
| this.container.querySelectorAll('[data-subsize]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| this.saveSettings(); | |
| }); | |
| // Subtitle Weight Menu | |
| this.container.querySelectorAll('[data-subweight]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| const cap = el('#txa-captions'); | |
| if (cap) { | |
| cap.classList.remove('weight-normal', 'weight-bold'); | |
| cap.classList.add('weight-' + i.dataset.subweight); | |
| } | |
| this.container.querySelectorAll('[data-subweight]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| this.saveSettings(); | |
| }); | |
| // Subtitle Background Menu | |
| this.container.querySelectorAll('[data-subbg]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| const cap = el('#txa-captions'); | |
| if (cap) { | |
| cap.classList.remove('bg-none', 'bg-dark', 'bg-light', 'bg-glass', 'bg-default'); | |
| if (i.dataset.subbg !== 'default') cap.classList.add('bg-' + i.dataset.subbg); | |
| } | |
| this.container.querySelectorAll('[data-subbg]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| this.saveSettings(); | |
| }); | |
| // Subtitle Color Menu | |
| this.container.querySelectorAll('[data-subcolor]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| const cap = el('#txa-captions'); | |
| if (cap) { | |
| cap.classList.remove('color-white', 'color-yellow', 'color-orange', 'color-red', 'color-green', 'color-cyan', 'color-pink', 'color-violet'); | |
| cap.classList.add('color-' + i.dataset.subcolor); | |
| } | |
| this.container.querySelectorAll('[data-subcolor]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| this.saveSettings(); | |
| }); | |
| // Subtitle Stroke Toggle | |
| const strokeToggle = el('#txa-substroke-toggle'); | |
| const strokePanel = el('#txa-stroke-panel'); | |
| if (strokeToggle) { | |
| strokeToggle.onchange = (e) => { | |
| const cap = el('#txa-captions'); | |
| if (cap) { | |
| if (e.target.checked) { | |
| cap.classList.add('stroke-on'); | |
| strokePanel.classList.add('active'); | |
| } else { | |
| cap.classList.remove('stroke-on'); | |
| strokePanel.classList.remove('active'); | |
| } | |
| } | |
| this.saveSettings(); | |
| }; | |
| } | |
| // Subtitle Stroke Color | |
| this.container.querySelectorAll('[data-substroke]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| const cap = el('#txa-captions'); | |
| if (cap) { | |
| cap.classList.remove('stroke-white', 'stroke-black', 'stroke-yellow', 'stroke-red', 'stroke-blue'); | |
| cap.classList.add('stroke-' + i.dataset.substroke); | |
| } | |
| this.container.querySelectorAll('[data-substroke]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| this.saveSettings(); | |
| }); | |
| // Real-time Clock Toggle v6.4.0 | |
| const clockToggle = el('#txa-clock-toggle'); | |
| if (clockToggle) { | |
| clockToggle.checked = this.settings.showRealTime; | |
| clockToggle.onchange = (e) => { | |
| this.settings.showRealTime = e.target.checked; | |
| this.updateClockVisibility(); | |
| this.saveSettings(); | |
| }; | |
| } | |
| // Clock Format Selection | |
| this.container.querySelectorAll('[data-clock]').forEach(i => i.onclick = (e) => { | |
| e.stopPropagation(); | |
| this.settings.clockFormat = i.dataset.clock; | |
| this.container.querySelectorAll('[data-clock]').forEach(o => o.classList.remove('active')); | |
| i.classList.add('active'); | |
| const lbl = el('#txa-lbl-clock'); | |
| if (lbl) lbl.textContent = this.settings.clockFormat; | |
| this.updateClock(); // Immediate update | |
| this.saveSettings(); | |
| }); | |
| // v6.4.5 - Robust Click/Touch Outside Logic for Panels | |
| this._handlers.docClick = (e) => { | |
| if (this.destroyed) return; | |
| const panel = el('#txa-panel'); | |
| const settingsBtn = el('#txa-settings'); | |
| const ctx = el('#txa-ctx'); | |
| const stats = el('#txa-stats-panel'); | |
| // 1. Settings Panel Click Outside | |
| if (panel && panel.classList.contains('active')) { | |
| // Check if target is NOT inside panel AND NOT inside settings button | |
| if (!panel.contains(e.target) && (!settingsBtn || !settingsBtn.contains(e.target))) { | |
| panel.classList.remove('active'); | |
| } | |
| } | |
| // 2. Context Menu Click Outside | |
| if (ctx && ctx.classList.contains('active')) { | |
| if (!ctx.contains(e.target)) ctx.classList.remove('active'); | |
| } | |
| // 3. Stats Panel Click Outside | |
| if (stats && stats.classList.contains('active')) { | |
| if (!stats.contains(e.target) && !e.target.closest('#txa-ctx-stats')) { | |
| this.toggleStatsPanel(); // Use clear cleanup logic | |
| } | |
| } | |
| }; | |
| document.addEventListener('click', this._handlers.docClick); | |
| document.addEventListener('touchstart', this._handlers.docClick, { passive: true }); | |
| this.wrapper.onmousemove = (e) => { | |
| if (this.detectMobile()) return; // Absolutely disable bottom hover for mobile | |
| const rect = this.wrapper.getBoundingClientRect(); | |
| const y = e.clientY - rect.top; | |
| // Only show UI if cursor is in the bottom 30% or if video is paused (Desktop) | |
| if (y > rect.height * 0.7 || this.video.paused) { | |
| this.showUI(); | |
| } | |
| }; | |
| this.wrapper.onclick = (e) => { | |
| // Desktop click logic (Mobile handled in handleMobileEvents) | |
| if (!this.detectMobile()) { | |
| if (!e.target.closest('.txa-controls,.txa-panel,.txa-ctx,.txa-replay-overlay,.txa-loader,.txa-stats-panel,.txa-resume-overlay,.txa-next-countdown,.txa-skip-btn')) this.showUI(); | |
| } | |
| }; | |
| el('#txa-zone-l').ondblclick = () => this.seekAccumulated(-10); | |
| el('#txa-zone-r').ondblclick = () => this.seekAccumulated(10); | |
| // REMOVED: Scroll wheel volume control (user request) | |
| // this.wrapper.onwheel = (e) => { ... }; | |
| el('#txa-cc').onclick = (e) => { e.stopPropagation(); this.toggleSubLoop(); }; | |
| // SUBTITLE DRAGGING | |
| this.setupSubtitleDrag(); | |
| // Initialize UI timer (Auto hide) | |
| this.showUI(); | |
| } | |
| /** | |
| * Centralized UI visibility logic | |
| * @param {boolean} autoHide - Whether to start a timer to hide the UI | |
| */ | |
| showUI(autoHide = true) { | |
| if (!this.wrapper || this.destroyed) return; | |
| this.wrapper.classList.add('active-ui'); | |
| if (this.controls) this.controls.classList.add('visible'); | |
| if (this._uiTimer) clearTimeout(this._uiTimer); | |
| // Always start timer if autoHide is requested | |
| if (autoHide && this.video) { | |
| const delay = this.detectMobile() ? 5000 : 3000; | |
| this._uiTimer = setTimeout(() => this.hideUI(), delay); | |
| } | |
| } | |
| hideUI() { | |
| if (!this.wrapper || this.destroyed) return; | |
| // Prevent hiding if video is paused | |
| if (!this.video || this.video.paused) return; | |
| // Prevent hiding if any interactable panel is open | |
| const el = (s) => this.container.querySelector(s); | |
| const panel = el('#txa-panel'); | |
| const ctx = el('#txa-ctx'); | |
| const stats = el('#txa-stats-panel'); | |
| const shortcuts = document.querySelector('.txa-shortcuts-panel'); | |
| if ((panel && panel.classList.contains('active')) || | |
| (ctx && ctx.classList.contains('active')) || | |
| (stats && stats.classList.contains('active')) || | |
| (shortcuts)) { | |
| // Something is active, reset timer to try again later | |
| this.showUI(); | |
| return; | |
| } | |
| this.wrapper.classList.remove('active-ui'); | |
| if (this.controls) this.controls.classList.remove('visible'); | |
| } | |
| setupSubtitleDrag() { | |
| const captions = this.container.querySelector('#txa-captions'); | |
| if (!captions) return; | |
| // Load saved position & styles | |
| const saved = this.loadSettings(); | |
| if (saved.captionBottom) captions.style.bottom = saved.captionBottom + 'px'; | |
| // Fix: Use pixel positioning if available | |
| if (saved.captionLeft !== undefined && saved.captionLeft !== null) { | |
| if (saved.captionLeft <= 100 && saved.captionIsPercent) { | |
| captions.style.left = saved.captionLeft + '%'; | |
| captions.style.transform = 'translateX(-50%)'; | |
| } else { | |
| captions.style.left = saved.captionLeft + 'px'; | |
| captions.style.transform = 'none'; | |
| } | |
| } | |
| // Apply saved styles | |
| if (saved.subSize) { | |
| captions.classList.add('size-' + saved.subSize); | |
| this.container.querySelectorAll('[data-subsize]').forEach(i => { | |
| i.classList.toggle('active', i.dataset.subsize === saved.subSize); | |
| }); | |
| } | |
| if (saved.subBg) { | |
| if (saved.subBg !== 'default') captions.classList.add('bg-' + saved.subBg); | |
| this.container.querySelectorAll('[data-subbg]').forEach(i => { | |
| i.classList.toggle('active', i.dataset.subbg === saved.subBg); | |
| }); | |
| } | |
| if (saved.subColor) { | |
| if (saved.subColor !== 'white') captions.classList.add('color-' + saved.subColor); | |
| this.container.querySelectorAll('[data-subcolor]').forEach(i => { | |
| i.classList.toggle('active', i.dataset.subcolor === saved.subColor); | |
| }); | |
| } | |
| let isDragging = false; | |
| let startX = 0, startY = 0; | |
| let startLeft = 0, startBottom = 0; | |
| captions.addEventListener('mousedown', (e) => { | |
| isDragging = true; | |
| captions.classList.add('dragging'); | |
| startX = e.clientX; | |
| startY = e.clientY; | |
| const style = getComputedStyle(captions); | |
| startBottom = parseInt(style.bottom) || 110; | |
| const rect = captions.getBoundingClientRect(); | |
| const pRect = this.wrapper.getBoundingClientRect(); | |
| // Calculate current relative position (Pixels) | |
| startLeft = rect.left - pRect.left; | |
| startBottom = pRect.bottom - rect.bottom; | |
| // Prepare for pixel drag | |
| captions.style.left = startLeft + 'px'; | |
| captions.style.bottom = startBottom + 'px'; | |
| captions.style.transform = 'none'; | |
| e.preventDefault(); | |
| }); | |
| this._handlers.dragMouseMove = (e) => { | |
| if (!isDragging) return; | |
| const rect = this.wrapper.getBoundingClientRect(); | |
| // Vertical movement | |
| const deltaY = startY - e.clientY; | |
| const newBottom = Math.max(20, Math.min(rect.height - 80, startBottom + deltaY)); | |
| captions.style.bottom = newBottom + 'px'; | |
| // Horizontal movement | |
| const deltaX = e.clientX - startX; | |
| const newLeft = Math.max(0, Math.min(rect.width - captions.offsetWidth, startLeft + deltaX)); // Boundary check | |
| captions.style.left = newLeft + 'px'; | |
| captions.style.transform = 'none'; | |
| }; | |
| this._handlers.dragMouseUp = () => { | |
| if (isDragging) { | |
| isDragging = false; | |
| captions.classList.remove('dragging'); | |
| // Save position (Pixels) | |
| const cfg = this.loadSettings(); | |
| cfg.captionBottom = parseInt(captions.style.bottom); | |
| cfg.captionLeft = parseFloat(captions.style.left); | |
| cfg.captionIsPercent = false; | |
| localStorage.setItem('txa-txa-cfg', JSON.stringify(cfg)); | |
| } | |
| }; | |
| window.addEventListener('mousemove', this._handlers.dragMouseMove); | |
| window.addEventListener('mouseup', this._handlers.dragMouseUp); | |
| } | |
| togglePlay() { | |
| this._isExplicitToggle = true; | |
| // v6.6.2 - Lazy setup on first interaction | |
| if (!this._mediaSetupDone) { | |
| const cp = this.container.querySelector('#txa-center-play'); | |
| if (cp) cp.classList.remove('show'); | |
| this.setupMedia(); | |
| // We don't need to call play() here because setupMedia with autoPlay=true will do it, | |
| // or if it's the first click, we might want to force it. | |
| // Actually, setupMedia might take time to resolve. | |
| // Let's ensure it plays after setup. | |
| this.options.autoPlay = true; | |
| return; | |
| } | |
| this.video.paused ? this.video.play().catch(() => { }) : this.video.pause(); | |
| // Clear the flag after a short delay | |
| setTimeout(() => { this._isExplicitToggle = false; }, 500); | |
| } | |
| animateCenter(type) { | |
| // Only run animation if it was an explicit user toggle | |
| if (!this._isExplicitToggle) return; | |
| // Remove existing if any | |
| let c = this.container.querySelector('.txa-center-feedback'); | |
| if (!c) { | |
| c = document.createElement('div'); | |
| c.className = 'txa-center-feedback'; | |
| this.container.appendChild(c); | |
| } | |
| c.innerHTML = type === 'play' ? '<i class="fas fa-play"></i>' : '<i class="fas fa-pause"></i>'; | |
| // Reset classes | |
| c.classList.remove('animate', 'animate-out'); | |
| void c.offsetWidth; // Trigger reflow | |
| c.classList.add('animate'); | |
| setTimeout(() => { | |
| if (c) { | |
| c.classList.add('animate-out'); | |
| setTimeout(() => { | |
| c.classList.remove('animate', 'animate-out'); | |
| }, 400); | |
| } | |
| }, 400); | |
| if (window.navigator.vibrate) window.navigator.vibrate(15); | |
| } | |
| showFeedback(type, value = null) { | |
| const fb = this.container.querySelector('#txa-feedback'); | |
| if (!fb) return; | |
| fb.classList.remove('high-priority'); // Reset | |
| if (type === 'volume') { | |
| fb.innerHTML = `<i class="fas fa-volume-${this.video.muted || this.video.volume === 0 ? 'mute' : this.video.volume < 0.5 ? 'down' : 'up'}"></i><span>${Math.round(this.video.volume * 100)}%</span>`; | |
| } else if (type === 'seek') { | |
| fb.innerHTML = `<i class="fas fa-forward"></i><span>${value}</span>`; | |
| } else if (type === 'text') { | |
| fb.innerHTML = `<span>${value}</span>`; | |
| } else if (type === 'high') { | |
| fb.innerHTML = `<span>${value}</span>`; | |
| fb.classList.add('high-priority'); | |
| } | |
| fb.classList.add('show'); | |
| clearTimeout(this.feedbackTimer); | |
| const duration = type === 'high' ? 3000 : 800; | |
| this.feedbackTimer = setTimeout(() => fb.classList.remove('show'), duration); | |
| } | |
| reportErrorToServer(errorType, errorMessage, errorDetails = {}) { | |
| const payload = { | |
| movie_slug: this.options.movieSlug || null, | |
| movie_name: this.options.title || null, | |
| episode_name: this.options.episodeName || null, | |
| error_type: errorType, | |
| error_message: errorMessage || '', | |
| error_details: { | |
| ...errorDetails, | |
| video_url: this.options.videoUrl || '', | |
| server_name: this.options.serverName || '', | |
| settings: this.settings || {}, | |
| txa_version: typeof TXA_VERSION !== 'undefined' ? TXA_VERSION : 'v1.0' | |
| }, | |
| browser_token: localStorage.getItem('zalo_browser_token') || null | |
| }; | |
| fetch('/api/player/report-error', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(payload) | |
| }) | |
| .then(res => res.json()) | |
| .then(data => { | |
| if (this.options.isAdmin) { | |
| console.log('[TXAPlayer] Error reported to server successfully:', data); | |
| } | |
| }) | |
| .catch(err => { | |
| console.error('[TXAPlayer] Failed to report error to server:', err); | |
| }); | |
| } | |
| _txaShowHlsError(msg, hlsData = null) { | |
| // Report HLS fatal error to database | |
| this.reportErrorToServer('hlsError', msg, hlsData); | |
| const loader = this.container?.querySelector('#txa-loader'); | |
| if (!loader) return; | |
| loader.classList.remove('hidden'); | |
| loader.style.pointerEvents = 'all'; | |
| const spinner = loader.querySelector('.txa-spinner'); | |
| const pctEl = loader.querySelector('#txa-load-pct'); | |
| const barFill = loader.querySelector('#txa-load-bar-fill'); | |
| const loadText = loader.querySelector('#txa-load-text'); | |
| const speedEl = loader.querySelector('#txa-load-speed'); | |
| if (spinner) { spinner.style.borderTopColor = '#f59e0b'; spinner.style.animationPlayState = 'paused'; } | |
| if (pctEl) { pctEl.textContent = '✕'; pctEl.style.color = '#f59e0b'; } | |
| if (barFill) { barFill.style.background = '#f59e0b'; barFill.style.width = '100%'; } | |
| if (speedEl) speedEl.textContent = ''; | |
| if (loadText) { | |
| loadText.style.color = '#fde68a'; | |
| loadText.innerHTML = `<i class="fas fa-exclamation-circle" style="color:#f59e0b; margin-right:6px;"></i>${msg}`; | |
| } | |
| if (hlsData && this.options.isAdmin) { | |
| console.error('[TXAPlayer] HLS Fatal:', hlsData.type, hlsData.details, hlsData.response); | |
| } | |
| if (!loader.querySelector('#txa-retry-btn')) { | |
| const retryBtn = document.createElement('button'); | |
| retryBtn.id = 'txa-retry-btn'; | |
| retryBtn.innerHTML = '<i class="fas fa-redo" style="margin-right:6px;"></i>Tải lại'; | |
| retryBtn.style.cssText = ` | |
| margin-top:14px; padding:8px 22px; | |
| background:#f59e0b; color:#000; border:none; | |
| border-radius:8px; cursor:pointer; font-size:13px; font-weight:700; | |
| pointer-events:all; | |
| `; | |
| retryBtn.onclick = () => window.location.reload(); | |
| const content = loader.querySelector('.txa-loader-content'); | |
| if (content) content.appendChild(retryBtn); | |
| } | |
| } | |
| setAspectRatio(ratio) { | |
| if (!this.video) return; | |
| this.video.classList.remove('ratio-4-3', 'ratio-16-9', 'ratio-stretch'); | |
| if (ratio !== 'default') this.video.classList.add('ratio-' + ratio); | |
| const lbl = this.container.querySelector('#txa-lbl-ratio'); | |
| if (lbl) lbl.textContent = ratio === 'default' ? 'Default' : ratio.replace('-', ':').toUpperCase(); | |
| // Update menu status | |
| this.container.querySelectorAll('[data-ratio]').forEach(i => i.classList.toggle('active', i.dataset.ratio === ratio)); | |
| this.showFeedback('text', 'Ratio: ' + (ratio === 'default' ? 'Default' : ratio)); | |
| this.saveSettings(); | |
| } | |
| setVideoFlip(flip) { | |
| if (!this.video) return; | |
| this.video.classList.remove('flip-h', 'flip-v', 'flip-both'); | |
| if (flip !== 'normal') this.video.classList.add('flip-' + flip); | |
| const lbl = this.container.querySelector('#txa-lbl-flip'); | |
| if (lbl) { | |
| const names = { normal: 'Normal', h: 'Horizontal', v: 'Vertical', both: 'Both' }; | |
| lbl.textContent = names[flip] || 'Normal'; | |
| } | |
| // Update menu status | |
| this.container.querySelectorAll('[data-flip]').forEach(i => i.classList.toggle('active', i.dataset.flip === flip)); | |
| this.showFeedback('text', 'Flip: ' + flip); | |
| this.saveSettings(); | |
| } | |
| seekAccumulated(sec) { | |
| this.seekAccumulator += sec; | |
| clearTimeout(this.seekDebounce); | |
| const badge = this.container.querySelector(this.seekAccumulator > 0 ? '#txa-badge-r' : '#txa-badge-l'); | |
| const other = this.container.querySelector(this.seekAccumulator > 0 ? '#txa-badge-l' : '#txa-badge-r'); | |
| if (other) other.classList.remove('show'); // Added null check | |
| if (badge) { // Added null check | |
| badge.querySelector('span').textContent = `${this.seekAccumulator > 0 ? '+' : ''}${this.seekAccumulator}s`; | |
| badge.classList.add('show'); | |
| } | |
| this.seekDebounce = setTimeout(() => { | |
| this.safeSeek(this.video.currentTime + this.seekAccumulator); | |
| this.seekAccumulator = 0; | |
| if (badge) badge.classList.remove('show'); // Added null check | |
| }, 700); | |
| } | |
| updateVolUI() { | |
| const v = this.video.muted ? 0 : this.video.volume; | |
| const btn = this.container.querySelector('#txa-mute'); | |
| const volInput = this.container.querySelector('#txa-vol'); | |
| const volTooltip = this.container.querySelector('#txa-vol-tooltip'); | |
| if (volInput) { | |
| volInput.value = v; | |
| const pct = v * 100; | |
| // Update the premium fill effect | |
| volInput.style.backgroundImage = `linear-gradient(to right, var(--txa-brand) ${pct}%, rgba(255,255,255,0.15) ${pct}%)`; | |
| if (volTooltip) { | |
| volTooltip.textContent = Math.round(pct) + '%'; | |
| // Precise positioning follow thumb | |
| const sliderWidth = 115; | |
| const thumbWidth = 18; | |
| const btnWidth = 42; | |
| const usableWidth = sliderWidth - thumbWidth; | |
| // Offset calculation relative to volume container | |
| const leftOffset = btnWidth + 12 + (v * usableWidth) + (thumbWidth / 2); | |
| volTooltip.style.left = leftOffset + 'px'; | |
| } | |
| } | |
| if (btn) { | |
| const icon = v === 0 ? 'mute' : v < 0.5 ? 'down' : 'up'; | |
| btn.innerHTML = `<i class="fas fa-volume-${icon}"></i><div class="txa-tooltip">${v === 0 ? 'Unmute' : 'Mute'} (m)</div>`; | |
| if (v === 0) btn.style.opacity = '0.6'; | |
| else btn.style.opacity = '1'; | |
| } | |
| } | |
| formatTime(s, forceHours = false) { | |
| if (!s || isNaN(s)) return '00:00'; | |
| const h = Math.floor(s / 3600); | |
| const m = Math.floor((s % 3600) / 60); | |
| const sec = Math.floor(s % 60); | |
| const pad = n => n < 10 ? '0' + n : n; | |
| // If duration >= 1 hour OR forceHours, show HH:MM:SS | |
| if (forceHours || h > 0) { | |
| return `${pad(h)}:${pad(m)}:${pad(sec)}`; | |
| } | |
| return `${pad(m)}:${pad(sec)}`; | |
| } | |
| /** | |
| * Safe seek - prevents "non-finite" errors when duration/time is NaN or Infinity | |
| */ | |
| safeSeek(time) { | |
| if (!this.video) return; | |
| if (!isFinite(time) || isNaN(time)) return; | |
| const dur = this.video.duration; | |
| if (!isFinite(dur) || isNaN(dur) || dur <= 0) return; | |
| this.video.currentTime = Math.max(0, Math.min(dur, time)); | |
| } | |
| formatTimeDisplay() { | |
| const current = this.video.currentTime || 0; | |
| const duration = this.video.duration || 0; | |
| return `${this.formatTime(current, duration >= 3600)} / ${this.formatTime(duration)}`; | |
| } | |
| async toggleFS() { | |
| try { | |
| const isFS = document.fullscreenElement || document.webkitFullscreenElement; | |
| if (isFS) { | |
| if (document.exitFullscreen) await document.exitFullscreen(); | |
| else if (document.webkitExitFullscreen) await document.webkitExitFullscreen(); | |
| if (screen.orientation && screen.orientation.unlock) { | |
| try { await screen.orientation.unlock(); } catch (e) { } | |
| } | |
| } else { | |
| if (this.wrapper.requestFullscreen) await this.wrapper.requestFullscreen(); | |
| else if (this.wrapper.webkitRequestFullscreen) await this.wrapper.webkitRequestFullscreen(); | |
| else if (this.video.webkitEnterFullscreen) { | |
| this.video.webkitEnterFullscreen(); | |
| return; | |
| } | |
| if (this.detectMobile() && screen.orientation && screen.orientation.lock) { | |
| try { await screen.orientation.lock('landscape'); } catch (e) { } | |
| } | |
| } | |
| } catch (err) { | |
| console.error('FS Error:', err); | |
| if (this.video.webkitEnterFullscreen) this.video.webkitEnterFullscreen(); | |
| } | |
| } | |
| skipIntro() { | |
| if (!this.options.isLoggedIn) return; | |
| const intro = this.options.markers.intro; | |
| if (intro) { | |
| this.safeSeek(intro[1]); | |
| this.showToast('🚀 Bỏ qua Intro'); | |
| } | |
| } | |
| skipOutro() { | |
| if (!this.options.isLoggedIn) return; | |
| const outro = this.options.markers.outro; | |
| if (!outro) return; | |
| const effectiveOutroOut = (outro[1] > 0) ? outro[1] : this.video.duration; | |
| if (this.options.nextEpisodeUrl && Math.abs(effectiveOutroOut - this.video.duration) < 5) { | |
| if (this.options.onAutoNext) { | |
| this.options.onAutoNext(true); | |
| } else if (typeof window.switchEpisodeSeamless === 'function' && this.options.nextEpisodeUrl) { | |
| window.switchEpisodeSeamless(this.options.nextEpisodeUrl, true); | |
| } else { | |
| window.location.href = this.options.nextEpisodeUrl; | |
| } | |
| return; | |
| } | |
| if (effectiveOutroOut >= this.video.duration - 5) { | |
| this.video.pause(); | |
| const replay = this.container.querySelector('#txa-replay'); | |
| if (replay) replay.classList.add('show'); | |
| } else { | |
| this.safeSeek(outro[1]); | |
| this.showToast('🚀 Bỏ qua Outro'); | |
| } | |
| } | |
| toggleAutoSkipIntro() { | |
| this.settings.autoSkipIntro = !this.settings.autoSkipIntro; | |
| this.saveSettings(); | |
| const text = this.settings.autoSkipIntro ? 'Auto Skip Intro: Bật' : 'Auto Skip Intro: Tắt'; | |
| this.showToast('🚀 ' + text); | |
| this.updatePeSwitches(); | |
| } | |
| toggleAutoNextEpisode() { | |
| this.settings.autoNextEpisode = !this.settings.autoNextEpisode; | |
| this.saveSettings(); | |
| const text = this.settings.autoNextEpisode ? 'Auto Next: Bật' : 'Auto Next: Tắt'; | |
| this.showToast('⏭️ ' + text); | |
| this.updatePeSwitches(); | |
| } | |
| updatePeSwitches() { | |
| // Update Internal Toolbar Buttons | |
| const skipBtn = this.container ? this.container.querySelector('#txa-auto-skip') : null; | |
| if (skipBtn) { | |
| const isOn = this.settings.autoSkipIntro; | |
| skipBtn.classList.toggle('active', isOn); | |
| const statusEl = skipBtn.querySelector('.txa-btn-status'); | |
| if (statusEl) statusEl.textContent = isOn ? 'ON' : 'OFF'; | |
| const tooltipEl = skipBtn.querySelector('.txa-tooltip'); | |
| if (tooltipEl) tooltipEl.textContent = `Tự động bỏ qua giới thiệu (${isOn ? 'Bật' : 'Tắt'})`; | |
| } | |
| const nextBtn = this.container ? this.container.querySelector('#txa-auto-next') : null; | |
| if (nextBtn) { | |
| const isOn = this.settings.autoNextEpisode; | |
| nextBtn.classList.toggle('active', isOn); | |
| const statusEl = nextBtn.querySelector('.txa-btn-status'); | |
| if (statusEl) statusEl.textContent = isOn ? 'ON' : 'OFF'; | |
| const tooltipEl = nextBtn.querySelector('.txa-tooltip'); | |
| if (tooltipEl) tooltipEl.textContent = `Tự động chuyển tập (${isOn ? 'Bật' : 'Tắt'})`; | |
| } | |
| // Sync external UI if needed | |
| const extAutoSkip = document.querySelector('.pe-item.auto-skip-intro'); | |
| if (extAutoSkip) { | |
| extAutoSkip.classList.toggle('active-pro', this.settings.autoSkipIntro); | |
| extAutoSkip.setAttribute('data-txa-tooltip', `Tự động bỏ qua Intro (${this.settings.autoSkipIntro ? 'BẬT' : 'TẮT'})`); | |
| } | |
| const extAutoNext = document.querySelector('.pe-item.auto-next'); | |
| if (extAutoNext) { | |
| extAutoNext.classList.toggle('active-pro', this.settings.autoNextEpisode); | |
| extAutoNext.setAttribute('data-txa-tooltip', `Tự động chuyển tập (${this.settings.autoNextEpisode ? 'BẬT' : 'TẮT'})`); | |
| } | |
| } | |
| /** | |
| * Show auto-next countdown overlay when video ends (without outro markers) | |
| */ | |
| _showAutoNextCountdown() { | |
| console.log('[TXAPlayer] _showAutoNextCountdown called', { | |
| autoNextEpisode: this.settings.autoNextEpisode, | |
| nextEpisodeUrl: this.options.nextEpisodeUrl, | |
| isAutoNextTriggered: this._isAutoNextTriggered | |
| }); | |
| const overlay = this.container.querySelector('#txa-next-countdown'); | |
| const timeEl = this.container.querySelector('#txa-nc-timer'); | |
| const titleEl = this.container.querySelector('#txa-nc-title'); | |
| const badgeEl = this.container.querySelector('#txa-nc-badge'); | |
| if (!overlay) { | |
| console.warn('[TXAPlayer] Countdown overlay not found in DOM'); | |
| return; | |
| } | |
| // Set title | |
| if (titleEl) titleEl.textContent = this.options.title || 'Tập tiếp theo'; | |
| // Smart Badge: Try to detect next episode number | |
| if (badgeEl) { | |
| let nextNum = '--'; | |
| try { | |
| const url = this.options.nextEpisodeUrl || ''; | |
| const curName = this.options.episodeName || ''; | |
| const matchUrl = url.match(/(tap|ep|episode)[\-_](\d+)/i) || url.match(/-(\d+)\.html/) || url.match(/-(\d+)$/); | |
| if (matchUrl) { | |
| nextNum = matchUrl[matchUrl.length - 1]; | |
| } else { | |
| const matchCurr = curName.match(/(\d+)/); | |
| if (matchCurr) { | |
| const num = parseInt(matchCurr[1]) + 1; | |
| nextNum = (matchCurr[1].startsWith('0') && num < 10) ? '0' + num : num; | |
| } | |
| } | |
| } catch (e) { } | |
| badgeEl.innerHTML = `<span>Tập</span>${nextNum}`; | |
| } | |
| // Show overlay | |
| overlay.classList.add('show'); | |
| if (this.wrapper) this.wrapper.classList.add('has-countdown'); | |
| // Start countdown | |
| let countdown = 5; | |
| if (timeEl) { | |
| timeEl.textContent = `Bắt đầu sau 0${countdown}s`; | |
| } | |
| const countdownInterval = setInterval(() => { | |
| countdown--; | |
| if (countdown <= 0) { | |
| clearInterval(countdownInterval); | |
| overlay.classList.remove('show'); | |
| if (this.wrapper) this.wrapper.classList.remove('has-countdown'); | |
| // Trigger auto-next | |
| this._isAutoNextTriggered = true; | |
| this._triggerAutoNext(); | |
| } else { | |
| if (timeEl) { | |
| timeEl.textContent = `Bắt đầu sau 0${countdown}s`; | |
| } | |
| } | |
| }, 1000); | |
| // Store interval ID to clear if needed | |
| this._autoNextCountdownInterval = countdownInterval; | |
| } | |
| /** | |
| * Trigger auto-next episode switch | |
| */ | |
| _triggerAutoNext() { | |
| // Always prefer window.switchEpisodeSeamless if available (for seamless SPA experience) | |
| if (typeof window.switchEpisodeSeamless === 'function' && this.options.nextEpisodeUrl) { | |
| console.log('[TXAPlayer] Using switchEpisodeSeamless for auto-next'); | |
| window.switchEpisodeSeamless(this.options.nextEpisodeUrl, true); | |
| } else if (this.options.onAutoNext) { | |
| this.options.onAutoNext(true); | |
| } else if (window.loadEpisode && typeof window.loadEpisode === 'function') { | |
| window.loadEpisode(this.options.nextEpisodeUrl); | |
| } else if (this.options.nextEpisodeUrl) { | |
| console.warn('[TXAPlayer] No seamless switch available, falling back to page reload'); | |
| window.location.href = this.options.nextEpisodeUrl; | |
| } | |
| } | |
| checkMarkers() { | |
| if (!this.options.isLoggedIn || !this.options.markers) return; | |
| const ct = this.video.currentTime; | |
| const dur = this.video.duration; | |
| const { intro, outro } = this.options.markers; | |
| // Player Internal Buttons | |
| const skipIntro = this.container.querySelector('#txa-skip-intro'); | |
| const skipOutro = this.container.querySelector('#txa-skip-outro'); | |
| // External Buttons (for TPhimX Tools) | |
| const extIntro = document.querySelector('.skip-intro-btn'); | |
| const extOutro = document.querySelector('.skip-outro-btn'); | |
| // Initial UI sync for external switches | |
| if (!this._peSwitchesInited) { | |
| this.updatePeSwitches(); | |
| this._peSwitchesInited = true; | |
| } | |
| // Skip Intro logic | |
| if (intro && ct >= intro[0] && ct <= intro[1]) { | |
| if (skipIntro) skipIntro.style.display = 'block'; | |
| if (extIntro) extIntro.classList.remove('d-none'); | |
| if (this.settings.autoSkipIntro && ct < intro[1]) { | |
| this.safeSeek(intro[1]); | |
| this.showToast('🚀 Đã tự động bỏ qua Intro'); | |
| } | |
| } else { | |
| if (skipIntro) skipIntro.style.display = 'none'; | |
| if (extIntro) extIntro.classList.add('d-none'); | |
| } | |
| // Skip Outro / Next Episode logic | |
| // STRICT CHECK: Only trigger if Outro is VALID (Start > 0) per user request | |
| const isValidOutro = outro && (outro[1] > outro[0] || (outro[0] > 0 && (outro[1] === 0 || !outro[1]))) && outro[0] > 0; | |
| const effectiveOutroOut = isValidOutro ? (outro[1] > 0 ? outro[1] : dur) : 0; | |
| if (isValidOutro && ct >= outro[0] && ct <= effectiveOutroOut) { | |
| if (skipOutro) { | |
| skipOutro.style.display = 'block'; | |
| // If we are very close to end, change text | |
| if (this.options.nextEpisodeUrl && Math.abs(dur - ct) < 15) { | |
| skipOutro.innerHTML = '<i class="fas fa-step-forward"></i> Tập Tiếp'; | |
| } | |
| } | |
| if (extOutro) extOutro.classList.remove('d-none'); | |
| // Auto Next Countdown & Logic | |
| // Only runs if Auto Next is enabled AND we have a valid Outro | |
| if (this.settings.autoNextEpisode && this.options.nextEpisodeUrl && !this._isAutoNextTriggered) { | |
| const triggerWait = 5; // 5s countdown | |
| const overlay = this.container.querySelector('#txa-next-countdown'); | |
| const timeEl = this.container.querySelector('#txa-nc-timer'); | |
| const titleEl = this.container.querySelector('#txa-nc-title'); | |
| const badgeEl = this.container.querySelector('#txa-nc-badge'); | |
| // Show Overlay | |
| if (overlay && !overlay.classList.contains('show')) { | |
| if (titleEl) titleEl.textContent = this.options.title || 'Tập tiếp theo'; | |
| // Smart Badge: Try to detect next episode number | |
| if (badgeEl) { | |
| let nextNum = '--'; | |
| try { | |
| const url = this.options.nextEpisodeUrl || ''; | |
| const curName = this.options.episodeName || ''; | |
| const matchUrl = url.match(/(tap|ep|episode)[\-_](\d+)/i) || url.match(/-(\d+)\.html/) || url.match(/-(\d+)$/); | |
| if (matchUrl) { | |
| nextNum = matchUrl[matchUrl.length - 1]; | |
| } else { | |
| const matchCurr = curName.match(/(\d+)/); | |
| if (matchCurr) { | |
| const num = parseInt(matchCurr[1]) + 1; | |
| nextNum = (matchCurr[1].startsWith('0') && num < 10) ? '0' + num : num; | |
| } | |
| } | |
| } catch (e) { } | |
| badgeEl.innerHTML = `<span>Tập</span>${nextNum}`; | |
| } | |
| overlay.classList.add('show'); | |
| this.wrapper.classList.add('has-countdown'); | |
| if (skipOutro) skipOutro.style.display = 'none'; | |
| } | |
| // Calculate Countdown | |
| const elapsedInOutro = ct - outro[0]; | |
| const remain = Math.max(0, triggerWait - Math.floor(elapsedInOutro)); | |
| if (timeEl) { | |
| const fmt = (remain < 10 ? '0' + remain : remain) + 's'; | |
| timeEl.textContent = `Bắt đầu sau ${fmt}`; | |
| } | |
| // Trigger Next | |
| if (elapsedInOutro >= triggerWait) { | |
| this._isAutoNextTriggered = true; | |
| if (overlay) overlay.classList.remove('show'); | |
| this.wrapper.classList.remove('has-countdown'); | |
| this._triggerAutoNext(); | |
| } | |
| } | |
| } else { | |
| // Not in Outro / Invalid Outro | |
| if (skipOutro) skipOutro.style.display = 'none'; | |
| if (extOutro) extOutro.classList.add('d-none'); | |
| // Clean up if we sought out of outro | |
| const overlay = this.container.querySelector('#txa-next-countdown'); | |
| if (overlay && overlay.classList.contains('show')) { | |
| overlay.classList.remove('show'); | |
| this.wrapper.classList.remove('has-countdown'); | |
| } | |
| } | |
| } | |
| setupShortcuts() { | |
| this._handlers.winKeyDown = (e) => { | |
| if (this.destroyed) return; | |
| if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return; | |
| const k = e.key.toLowerCase(); | |
| // Prevent scrolling for arrow keys if player is in focus or just generally | |
| if ([' ', 'k', 'f', 'm', 'p', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(k)) { | |
| // Only prevent default if it's a player key | |
| } | |
| switch (k) { | |
| case ' ': case 'k': e.preventDefault(); this.togglePlay(); break; | |
| case 'f': this.toggleFS(); break; | |
| case 'm': this.video.muted = !this.video.muted; this.updateVolUI(); this.showFeedback('volume'); this.saveSettings(); break; | |
| case 'p': this.toggleNativePiP(); break; | |
| case 'j': case 'arrowleft': this.seekAccumulated(-10); break; | |
| case 'l': case 'arrowright': this.seekAccumulated(10); break; | |
| case 'arrowup': e.preventDefault(); this.video.volume = Math.min(1, this.video.volume + 0.05); this.updateVolUI(); this.showFeedback('volume'); this.saveSettings(); break; | |
| case 'arrowdown': e.preventDefault(); this.video.volume = Math.max(0, this.video.volume - 0.05); this.updateVolUI(); this.showFeedback('volume'); this.saveSettings(); break; | |
| case 'c': this.toggleSubLoop(); break; | |
| case 'i': this.skipIntro(); break; | |
| case 'o': this.skipOutro(); break; | |
| case 't': this.toggleTheaterMode(); break; // v5.8.0 - Theater Mode | |
| case 'a': this.toggleCinemaMode(); break; // v5.8.0 - Ambient Light | |
| case 'escape': { | |
| const el = (s) => this.container.querySelector(s); | |
| // 1. Close dynamic shortcuts panel | |
| const sc = document.querySelector('.txa-shortcuts-panel'); | |
| if (sc) sc.remove(); | |
| // 2. Toggle off Stats Panel if active | |
| const stats = el('#txa-stats-panel'); | |
| if (stats && stats.classList.contains('active')) this.toggleStatsPanel(); | |
| // 3. Hide Settings Panel | |
| const settings = el('#txa-panel'); | |
| if (settings && settings.classList.contains('active')) settings.classList.remove('active'); | |
| // 4. Hide Context Menu | |
| const ctx = el('#txa-ctx'); | |
| if (ctx && ctx.classList.contains('active')) ctx.classList.remove('active'); | |
| // 5. Hide Resume Overlay | |
| const resume = el('#txa-resume'); | |
| if (resume && resume.classList.contains('show')) { | |
| resume.classList.remove('show'); | |
| this.resumeShown = true; | |
| } | |
| // 6. Cancel Next Episode Countdown | |
| const next = el('#txa-next-countdown'); | |
| if (next && next.classList.contains('show')) { | |
| next.classList.remove('show'); | |
| this._isAutoNextTriggered = true; | |
| } | |
| // 7. Exit Theater Mode | |
| if (this.isTheaterMode) this.toggleTheaterMode(); | |
| break; | |
| } | |
| case '/': { | |
| if (e.shiftKey) { | |
| e.preventDefault(); | |
| this.showShortcuts(); | |
| } else { | |
| // Search Shortcut Focus | |
| e.preventDefault(); | |
| setTimeout(() => { | |
| const searchInput = document.querySelector('input[name="keyword"]') || | |
| document.querySelector('#search-keyword') || | |
| document.querySelector('.search-input'); | |
| if (searchInput) { | |
| searchInput.focus(); | |
| searchInput.select(); | |
| } | |
| }, 50); | |
| } | |
| break; | |
| } | |
| default: | |
| if (k >= '0' && k <= '9') { | |
| const pct = parseInt(k) * 10; | |
| const t = (pct / 100) * this.video.duration; | |
| this.safeSeek(t); | |
| this.showFeedback('seek', `${pct}% (${this.formatTime(t)})`); | |
| } | |
| } | |
| }; | |
| window.addEventListener('keydown', this._handlers.winKeyDown); | |
| } | |
| // initSubtitles merged with version above to avoid duplication logic bugs | |
| async toggleSub(idx) { | |
| const lbl = this.container.querySelector('#txa-lbl-subs'); | |
| const btn = this.container.querySelector('#txa-cc'); | |
| const subs = this.options.subtitles; | |
| // Clear previous subtitles immediately to avoid "stuck" captions while loading | |
| this.currentSubCues = []; | |
| this.renderSubtitles(); | |
| if (idx === -1) { | |
| if (lbl) lbl.textContent = 'Off'; // Added null check | |
| this.activeSubIdx = -1; | |
| if (btn) btn.classList.remove('cc-active'); // Added null check | |
| this.showFeedback('text', 'Subtitles: Off'); | |
| } else { | |
| const sub = subs && subs[idx]; | |
| if (!sub) { | |
| if (lbl) lbl.textContent = 'Off'; | |
| this.activeSubIdx = -1; | |
| if (btn) btn.classList.remove('cc-active'); | |
| return; | |
| } | |
| if (lbl) lbl.textContent = sub.label || sub.lang; // Added null check | |
| this.activeSubIdx = idx; | |
| if (btn) btn.classList.add('cc-active'); // Added null check | |
| this.showFeedback('text', `Subtitles: ${sub.label || sub.lang}`); | |
| let txt = sub.content || ''; | |
| if (sub.file) { | |
| try { | |
| txt = await (await fetch(sub.file)).text(); | |
| } catch (e) { | |
| console.error('Subtitle fetch failed:', e); | |
| this.showToast('Lỗi tải phụ đề'); | |
| } | |
| } | |
| this.currentSubCues = this.parseVTT(txt); | |
| // Re-render immediately so the correct subtitle shows up at current time | |
| this.renderSubtitles(); | |
| } | |
| this.navSettings('main'); | |
| // Update active class in menu list | |
| const list = this.container.querySelector('#txa-list-subs'); | |
| if (list) { | |
| list.querySelectorAll('.txa-menu-item').forEach(o => o.classList.remove('active')); | |
| list.querySelector(`[data-sub="${idx}"]`)?.classList.add('active'); | |
| } | |
| } | |
| toggleSubLoop() { | |
| if (this.currentSubCues.length > 0) this.toggleSub(-1); | |
| else if (this.options.subtitles?.length > 0) this.toggleSub(0); | |
| } | |
| renderSubtitles() { | |
| const box = this.container.querySelector('#txa-captions'); | |
| if (!box) return; // Added null check | |
| if (!this.currentSubCues.length) { box.innerHTML = ''; return; } | |
| const ct = this.video.currentTime; | |
| const cue = this.currentSubCues.find(c => ct >= c.start && ct <= c.end); | |
| box.innerHTML = cue ? `<div class="txa-cue">${cue.text}</div>` : ''; | |
| } | |
| parseVTT(content) { | |
| if (!content) return []; | |
| const cues = []; | |
| // Clean up content: Remove WEBVTT header and metadata | |
| let processed = content.replace(/^WEBVTT.*\r?\n/i, ''); | |
| // Convert SRT commas to dots for universal parsing | |
| processed = processed.replace(/(\d+:\d+:\d+),(\d+)/g, '$1.$2'); | |
| const lines = processed.split(/\r?\n/); | |
| let start = null, end = null, text = []; | |
| const parseTime = (t) => { | |
| t = t.trim().replace(/[\[\]]/g, ''); | |
| const p = t.split(':'); | |
| if (p.length === 3) { | |
| // HH:MM:SS.mmm or MM:SS.mmm | |
| const hours = parseInt(p[0]) || 0; | |
| const minutes = parseInt(p[1]) || 0; | |
| const seconds = parseFloat(p[2].replace(',', '.')) || 0; | |
| return hours * 3600 + minutes * 60 + seconds; | |
| } else if (p.length === 2) { | |
| const minutes = parseInt(p[0]) || 0; | |
| const seconds = parseFloat(p[1].replace(',', '.')) || 0; | |
| return minutes * 60 + seconds; | |
| } | |
| return parseFloat(t) || 0; | |
| }; | |
| lines.forEach(line => { | |
| const l = line.trim(); | |
| if (l.includes('-->')) { | |
| // Time range line | |
| if (start !== null && text.length > 0) { | |
| cues.push({ start, end: end || start + 5, text: text.join('<br>') }); | |
| text = []; | |
| } | |
| const p = l.split('-->'); | |
| start = parseTime(p[0]); | |
| end = parseTime(p[1]); | |
| } else if (l === '') { | |
| // Separator | |
| if (start !== null && text.length > 0) { | |
| cues.push({ start, end: end || start + 5, text: text.join('<br>') }); | |
| start = null; end = null; text = []; | |
| } | |
| } else if (/^\d+$/.test(l)) { | |
| // Index line (SRT) - ignore | |
| } else if (l !== '') { | |
| // Text line | |
| if (start !== null) text.push(l); | |
| } | |
| }); | |
| // Push last cue | |
| if (start !== null && text.length > 0) { | |
| cues.push({ start, end: end || start + 5, text: text.join('<br>') }); | |
| } | |
| return cues; | |
| } | |
| navSettings(view) { | |
| const p = this.container.querySelector('#txa-panel'); | |
| if (!p) return; // Added null check | |
| p.querySelectorAll('[id^="txa-view-"]').forEach(d => d.classList.add('txa-hidden')); | |
| const target = p.querySelector(`#txa-view-${view}`); | |
| if (target) target.classList.remove('txa-hidden'); | |
| } | |
| setSpeed(rate) { | |
| this.video.playbackRate = rate; | |
| const speedLabel = this.container.querySelector('#txa-lbl-speed'); // Added null check | |
| if (speedLabel) speedLabel.textContent = rate === 1 ? 'Normal' : rate + 'x'; // Added null check | |
| this.navSettings('main'); | |
| this.saveSettings(); | |
| } | |
| setupContextMenu() { | |
| const el = (s) => this.container.querySelector(s); | |
| const ctx = el('#txa-ctx'); | |
| if (!ctx) return; | |
| if (this.detectMobile()) { | |
| this.wrapper.oncontextmenu = (e) => { | |
| if (this.destroyed) return; | |
| e.preventDefault(); | |
| return false; | |
| }; | |
| return; | |
| } | |
| this.wrapper.oncontextmenu = (e) => { | |
| if (this.destroyed) return; | |
| e.preventDefault(); | |
| const r = this.wrapper.getBoundingClientRect(); | |
| // Collision detection | |
| let left = e.clientX - r.left; | |
| let top = e.clientY - r.top; | |
| // If menu goes off right edge | |
| if (left + 250 > r.width) left = r.width - 250; | |
| // If menu goes off bottom edge | |
| if (top + 280 > r.height) top = r.height - 280; | |
| ctx.style.left = Math.max(10, left) + 'px'; | |
| ctx.style.top = Math.max(10, top) + 'px'; | |
| ctx.classList.add('active'); | |
| }; | |
| // Redundant ctxClick removed - now handled by docClick in bindEvents() | |
| /* | |
| this._handlers.ctxClick = (e) => { | |
| if (this.destroyed) return; | |
| if (ctx && !ctx.contains(e.target)) ctx.classList.remove('active'); | |
| }; | |
| document.addEventListener('click', this._handlers.ctxClick); | |
| */ | |
| // Ensure these el() calls find elements correctly | |
| const bindCtx = (id, fn) => { | |
| const item = el(id); | |
| if (item) item.onclick = () => { | |
| if (ctx) ctx.classList.remove('active'); | |
| fn(); | |
| }; | |
| }; | |
| bindCtx('#txa-ctx-snap', () => this.takeSnapshot()); | |
| bindCtx('#txa-ctx-url', () => { navigator.clipboard.writeText(location.href); this.showToast('URL Copied!'); }); | |
| bindCtx('#txa-ctx-loop', () => { | |
| this.video.loop = !this.video.loop; | |
| const state = this.video.loop ? 'On' : 'Off'; | |
| const loopStat = el('#txa-loop-stat'); // Added null check | |
| if (loopStat) loopStat.textContent = state; // Added null check | |
| this.showFeedback('text', `Loop Mode: ${state}`); | |
| }); | |
| bindCtx('#txa-ctx-stats', () => this.toggleStatsPanel()); | |
| bindCtx('#txa-ctx-sc', () => this.showShortcuts()); | |
| // Auto Toggle Handlers | |
| const introSw = el('#txa-sw-intro'); | |
| if (introSw) { | |
| introSw.onchange = (e) => { | |
| this.settings.autoSkipIntro = e.target.checked; | |
| this.saveSettings(); | |
| this.showToast(`Auto Skip Intro: ${e.target.checked ? 'ON' : 'OFF'}`); | |
| }; | |
| } | |
| const nextSw = el('#txa-sw-next'); | |
| if (nextSw) { | |
| nextSw.onchange = (e) => { | |
| this.settings.autoNextEpisode = e.target.checked; | |
| this.saveSettings(); | |
| this.showToast(`Auto Next: ${e.target.checked ? 'ON' : 'OFF'}`); | |
| }; | |
| } | |
| } | |
| showToast(msg) { | |
| const t = document.createElement('div'); | |
| t.className = 'txa-feedback show'; | |
| t.innerHTML = `<span>${msg}</span>`; | |
| if (this.container) this.container.appendChild(t); // Added null check | |
| setTimeout(() => t.remove(), 2500); | |
| } | |
| showShortcuts() { | |
| // Removed redundant pointer check that might fail on some Windows desktops | |
| const o = document.createElement('div'); | |
| o.className = 'txa-shortcuts-panel active'; | |
| o.id = 'txa-sc-panel'; | |
| const css = ` | |
| .txa-shortcuts-panel { | |
| background: rgba(0, 0, 0, 0.75); backdrop-filter: blur(25px); | |
| z-index: 10000; | |
| display: flex; align-items: center; justify-content: center; | |
| opacity: 0; animation: txafadeIn 0.3s forwards; | |
| } | |
| .txa-shortcuts-content { | |
| width: 900px; max-width: 90%; | |
| background: #0d0d0d; border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 24px; padding: 40px; | |
| position: relative; box-shadow: 0 30px 60px rgba(0,0,0,0.8); | |
| } | |
| .txa-shortcuts-header { | |
| display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 40px; | |
| } | |
| .txa-sc-title h3 { margin: 0; font-size: 28px; font-weight: 900; color: #fff; letter-spacing: -1px; } | |
| .txa-sc-title p { margin: 8px 0 0; font-size: 14px; color: #64748b; font-weight: 500; } | |
| .txa-sc-close { | |
| width: 44px; height: 44px; border-radius: 14px; border: none; | |
| background: rgba(255,255,255,0.05); color: #fff; cursor: pointer; | |
| transition: 0.3s; display: flex; align-items: center; justify-content: center; | |
| } | |
| .txa-sc-close:hover { background: #ef4444; transform: rotate(90deg); } | |
| .txa-sc-grid { | |
| display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; | |
| } | |
| .txa-sc-column { | |
| display: flex; flex-direction: column; gap: 15px; | |
| } | |
| .txa-sc-group-label { | |
| font-size: 11px; text-transform: uppercase; letter-spacing: 2px; | |
| color: var(--txa-brand, #8b5cf6); font-weight: 800; opacity: 0.8; | |
| padding-bottom: 10px; border-bottom: 1px solid rgba(255,255,255,0.05); | |
| } | |
| .txa-sc-item { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 12px 16px; background: rgba(255,255,255,0.02); | |
| border-radius: 12px; border: 1px solid transparent; transition: 0.2s; | |
| } | |
| .txa-sc-item:hover { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); } | |
| .txa-sc-name { color: #cbd5e1; font-size: 14px; font-weight: 500; } | |
| .txa-sc-keys { display: flex; gap: 6px; } | |
| kbd { | |
| background: #1a1a1a; border: 1px solid #333; border-bottom-width: 3px; | |
| color: #fff; border-radius: 6px; padding: 4px 8px; | |
| font-family: inherit; font-size: 11px; font-weight: 700; | |
| min-width: 20px; text-align: center; | |
| } | |
| .txa-sc-footer { | |
| margin-top: 40px; padding: 20px; border-radius: 16px; | |
| background: linear-gradient(to right, rgba(139,92,246,0.1), transparent); | |
| border: 1px solid rgba(139,92,246,0.2); display: flex; align-items: center; gap: 15px; | |
| } | |
| .txa-sc-footer i { font-size: 20px; color: #8b5cf6; } | |
| .txa-sc-footer p { margin: 0; font-size: 13px; color: #94a3b8; line-height: 1.5; } | |
| `; | |
| o.innerHTML = ` | |
| <style>${css}</style> | |
| <div class="txa-shortcuts-content" onclick="event.stopPropagation()"> | |
| <div class="txa-shortcuts-header"> | |
| <div class="txa-sc-title"> | |
| <h3>PHÍM TẮT</h3> | |
| <p>Trải nghiệm điều khiển điện ảnh với TPhimX Player</p> | |
| </div> | |
| <button class="txa-sc-close"><i class="fas fa-times"></i></button> | |
| </div> | |
| <div class="txa-sc-grid"> | |
| <div class="txa-sc-column"> | |
| <span class="txa-sc-group-label">ĐiềU KHIỂN CHÍNH</span> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Phát / Dừng</span><div class="txa-sc-keys"><kbd>Space</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Tua lại 10s</span><div class="txa-sc-keys"><kbd>J</kbd> / <kbd>←</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Tua tiếp 10s</span><div class="txa-sc-keys"><kbd>L</kbd> / <kbd>→</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Nhảy nhanh</span><div class="txa-sc-keys"><kbd>0-9</kbd></div></div> | |
| </div> | |
| <div class="txa-sc-column"> | |
| <span class="txa-sc-group-label">ÂM THANH & SUB</span> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Tăng âm lượng</span><div class="txa-sc-keys"><kbd>↑</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Giảm âm lượng</span><div class="txa-sc-keys"><kbd>↓</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Tắt tiếng</span><div class="txa-sc-keys"><kbd>M</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Đổi phụ đề</span><div class="txa-sc-keys"><kbd>C</kbd></div></div> | |
| </div> | |
| <div class="txa-sc-column"> | |
| <span class="txa-sc-group-label">GIAO DIỆN</span> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Toàn màn hình</span><div class="txa-sc-keys"><kbd>F</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Thu nhỏ (PiP)</span><div class="txa-sc-keys"><kbd>P</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Rạp chiếu phim</span><div class="txa-sc-keys"><kbd>T</kbd></div></div> | |
| <div class="txa-sc-item"><span class="txa-sc-name">Đóng Menu</span><div class="txa-sc-keys"><kbd>Esc</kbd></div></div> | |
| </div> | |
| </div> | |
| <div class="txa-sc-footer"> | |
| <i class="fas fa-keyboard"></i> | |
| <p>Mẹo: Nhấn đúp vào hai bên màn hình để tua nhanh trên điện thoại. <br>Hệ thống phím tắt chỉ hỗ trợ khi trình duyệt đang ở trạng thái tập trung.</p> | |
| </div> | |
| </div> | |
| `; | |
| const closeBtn = o.querySelector('.txa-sc-close'); | |
| if (closeBtn) { | |
| closeBtn.onclick = (e) => { e.stopPropagation(); o.remove(); }; | |
| } | |
| o.onclick = (e) => { o.remove(); }; | |
| if (this.wrapper) this.wrapper.appendChild(o); | |
| } | |
| loadInitialSettings() { | |
| const el = (s) => this.container.querySelector(s); | |
| if (this.settings.volume !== undefined) { | |
| this.video.volume = this.settings.volume; | |
| this.video.muted = this.settings.muted || false; | |
| this.updateVolUI(); | |
| } | |
| if (this.settings.playbackRate) { | |
| this.setSpeed(this.settings.playbackRate); | |
| } | |
| // v6.5.2 - Load Aspect Ratio & Flip | |
| if (this.settings.aspectRatio) { | |
| this.setAspectRatio(this.settings.aspectRatio); | |
| } | |
| if (this.settings.videoFlip) { | |
| this.setVideoFlip(this.settings.videoFlip); | |
| } | |
| if (this.settings.loop !== undefined) { | |
| this.video.loop = this.settings.loop; | |
| const loopStat = this.container.querySelector('#txa-loop-stat'); | |
| if (loopStat) loopStat.textContent = this.video.loop ? 'On' : 'Off'; | |
| } | |
| if (this.settings.activeSubIdx !== undefined && this.settings.activeSubIdx !== -1) { | |
| setTimeout(() => this.toggleSub(this.settings.activeSubIdx), 500); | |
| } | |
| const cap = el('#txa-captions'); | |
| const s = this.settings; | |
| if (cap && s.subStyle) { | |
| if (s.subStyle.size) { | |
| cap.classList.add('size-' + s.subStyle.size); | |
| this.container.querySelectorAll('[data-subsize]').forEach(o => { | |
| o.classList.toggle('active', o.dataset.subsize === s.subStyle.size); | |
| }); | |
| } | |
| if (s.subStyle.weight) { | |
| cap.classList.add('weight-' + s.subStyle.weight); | |
| this.container.querySelectorAll('[data-subweight]').forEach(o => { | |
| o.classList.toggle('active', o.dataset.subweight === s.subStyle.weight); | |
| }); | |
| } | |
| if (s.subStyle.bg) { | |
| if (s.subStyle.bg !== 'default') cap.classList.add('bg-' + s.subStyle.bg); | |
| this.container.querySelectorAll('[data-subbg]').forEach(o => { | |
| o.classList.toggle('active', o.dataset.subbg === s.subStyle.bg); | |
| }); | |
| } | |
| if (s.subStyle.color) { | |
| cap.classList.add('color-' + s.subStyle.color); | |
| this.container.querySelectorAll('[data-subcolor]').forEach(o => { | |
| o.classList.toggle('active', o.dataset.subcolor === s.subStyle.color); | |
| }); | |
| } | |
| if (s.subStyle.stroke) { | |
| cap.classList.add('stroke-on'); | |
| const strokeToggle = el('#txa-substroke-toggle'); | |
| if (strokeToggle) strokeToggle.checked = true; | |
| const strokePanel = el('#txa-stroke-panel'); | |
| if (strokePanel) strokePanel.classList.add('active'); | |
| } | |
| if (s.subStyle.strokeColor) { | |
| cap.classList.add('stroke-' + s.subStyle.strokeColor); | |
| this.container.querySelectorAll('[data-substroke]').forEach(o => { | |
| if (o.dataset.substroke === s.subStyle.strokeColor) o.classList.add('active'); | |
| else o.classList.remove('active'); | |
| }); | |
| } | |
| } | |
| // v6.4.0 - Real-time clock initial state | |
| const clockToggle = el('#txa-clock-toggle'); | |
| if (clockToggle) clockToggle.checked = this.settings.showRealTime; | |
| if (this.settings.clockFormat) { | |
| const lbl = el('#txa-lbl-clock'); | |
| if (lbl) lbl.textContent = this.settings.clockFormat; | |
| this.container.querySelectorAll('[data-clock]').forEach(o => { | |
| o.classList.toggle('active', o.dataset.clock === this.settings.clockFormat); | |
| }); | |
| } | |
| this.updateClockVisibility(); | |
| // v6.1.0 - Sync external switches | |
| this.updatePeSwitches(); | |
| } | |
| loadSettings() { | |
| const key = 'txa-p-conf'; | |
| let saved = localStorage.getItem(key); | |
| if (!saved) { | |
| // Migration for old keys | |
| const oldKeys = ['txa-txa-cfg', 'txa_player_settings', 'txa-player-v4-settings']; | |
| for (const ok of oldKeys) { | |
| const oldData = localStorage.getItem(ok); | |
| if (oldData) { | |
| localStorage.setItem(key, oldData); | |
| // Cleanup old keys | |
| oldKeys.forEach(k => localStorage.removeItem(k)); | |
| localStorage.removeItem('txa_player_state'); | |
| return JSON.parse(oldData); | |
| } | |
| } | |
| return {}; | |
| } | |
| try { return JSON.parse(saved) || {}; } catch { return {}; } | |
| } | |
| saveSettings() { | |
| if (!this.video || this.destroyed) return; | |
| const el = (s) => this.container.querySelector(s); | |
| const cap = el('#txa-captions'); | |
| const strokeToggle = el('#txa-substroke-toggle'); | |
| // Extract Aspect Ratio and Flip from classList | |
| const aspectRatio = Array.from(this.video.classList).find(c => c.startsWith('ratio-'))?.replace('ratio-', '') || 'default'; | |
| const videoFlip = Array.from(this.video.classList).find(c => c.startsWith('flip-'))?.replace('flip-', '') || 'normal'; | |
| const cfg = { | |
| volume: this.video.volume, | |
| muted: this.video.muted, | |
| playbackRate: this.video.playbackRate, | |
| loop: this.video.loop, | |
| activeSubIdx: this.activeSubIdx, | |
| aspectRatio: aspectRatio, | |
| videoFlip: videoFlip, | |
| subStyle: cap ? { | |
| size: Array.from(cap.classList).find(c => c.startsWith('size-'))?.replace('size-', ''), | |
| weight: Array.from(cap.classList).find(c => c.startsWith('weight-'))?.replace('weight-', ''), | |
| bg: Array.from(cap.classList).find(c => c.startsWith('bg-'))?.replace('bg-', '') || 'default', | |
| color: Array.from(cap.classList).find(c => c.startsWith('color-'))?.replace('color-', ''), | |
| stroke: strokeToggle ? strokeToggle.checked : false, | |
| strokeColor: Array.from(cap.classList).find(c => c.startsWith('stroke-'))?.replace('stroke-', '') | |
| } : {}, | |
| captionBottom: cap ? parseInt(cap.style.bottom) : 110, | |
| captionLeft: (cap && cap.style.left) ? parseFloat(cap.style.left) : undefined, | |
| autoSkipIntro: this.settings.autoSkipIntro, | |
| autoNextEpisode: this.settings.autoNextEpisode, | |
| brightness: this.options.brightness || 1, | |
| showRealTime: this.settings.showRealTime, | |
| clockFormat: this.settings.clockFormat | |
| }; | |
| localStorage.setItem('txa-p-conf', JSON.stringify(cfg)); | |
| // v6.0.0 - Sync to Cloud if saveSettingsUrl is provided | |
| if (this.options.saveSettingsUrl && this.options.csrfToken) { | |
| fetch(this.options.saveSettingsUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'X-CSRF-TOKEN': this.options.csrfToken | |
| }, | |
| body: JSON.stringify({ settings: cfg }) | |
| }).catch(e => console.warn('[TXAPlayer] Settings sync failed:', e)); | |
| } | |
| } | |
| takeSnapshot() { | |
| try { | |
| const c = document.createElement('canvas'); | |
| c.width = this.video.videoWidth; | |
| c.height = this.video.videoHeight; | |
| const ctx = c.getContext('2d'); | |
| // Draw video frame | |
| ctx.drawImage(this.video, 0, 0); | |
| const watermark = `TPHIMX Player - ${TXA_VERSION} `; | |
| const fontSize = Math.max(18, c.height * 0.03); | |
| ctx.font = `bold ${fontSize}px Inter, sans - serif`; | |
| ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'bottom'; | |
| ctx.shadowColor = 'rgba(0, 0, 0, 0.7)'; | |
| ctx.shadowBlur = 6; | |
| ctx.fillText(watermark, c.width - 25, c.height - 20); | |
| const a = document.createElement('a'); | |
| a.download = `tphimx - snapshot - ${Date.now()}.png`; | |
| a.href = c.toDataURL('image/png', 1.0); | |
| a.click(); | |
| this.showToast('Snapshot 4K Captured!'); | |
| } catch (e) { | |
| console.error('TXAPlayer Snapshot Error:', e); | |
| if (e.name === 'SecurityError') { | |
| this.showToast('Lỗi bảo mật: Không thể chụp ảnh từ máy chủ này (CORS)'); | |
| } else { | |
| this.showToast('Không thể chụp ảnh màn hình lúc này'); | |
| } | |
| } | |
| } | |
| toggleView(v) { | |
| if (v === 'shortcuts') this.showShortcuts(); | |
| if (v === 'stats') this.toggleStatsPanel(); | |
| } | |
| toggleStatsPanel() { | |
| const panel = this.container.querySelector('#txa-stats-panel'); | |
| if (!panel) return; | |
| if (panel.classList.contains('active')) { | |
| panel.classList.remove('active'); | |
| if (this._statsInterval) { clearInterval(this._statsInterval); this._statsInterval = null; } | |
| return; | |
| } | |
| panel.classList.add('active'); | |
| this.updateStatsPanel(); | |
| // Update every 1s | |
| this._statsInterval = setInterval(() => { | |
| if (this.destroyed || !panel.classList.contains('active')) { | |
| clearInterval(this._statsInterval); | |
| this._statsInterval = null; | |
| return; | |
| } | |
| this.updateStatsPanel(); | |
| }, 1000); | |
| // Close button | |
| const closeBtn = this.container.querySelector('#txa-stats-close'); | |
| if (closeBtn) closeBtn.onclick = () => this.toggleStatsPanel(); | |
| } | |
| updateStatsPanel() { | |
| const grid = this.container.querySelector('#txa-stats-grid'); | |
| if (!grid || !this.video) return; | |
| const v = this.video; | |
| const fmt = window.txaformat; | |
| // Gather stats | |
| const resolution = `${v.videoWidth || '?'}×${v.videoHeight || '?'} `; | |
| const currentTime = this.formatTime(v.currentTime); | |
| const duration = this.formatTime(v.duration); | |
| const ahead = (v.buffered && v.buffered.length > 0) ? (v.buffered.end(v.buffered.length - 1) - v.currentTime) : 0; | |
| // Fast DOM Update | |
| const html = ` | |
| < div class="txa-stat-item" ><div class="txa-stat-label">Resolution</div><div class="txa-stat-value brand">${resolution}</div></div > | |
| <div class="txa-stat-item"><div class="txa-stat-label">Speed</div><div class="txa-stat-value">${v.playbackRate}x</div></div> | |
| <div class="txa-stat-item full"><div class="txa-stat-label">Progress</div><div class="txa-stat-value">${currentTime} / ${duration}</div></div> | |
| <div class="txa-stat-item"><div class="txa-stat-label">Buffer</div><div class="txa-stat-value ${ahead > 10 ? 'green' : ahead > 3 ? 'yellow' : 'red'}">${ahead.toFixed(1)}s ahead</div></div> | |
| <div class="txa-stat-item"><div class="txa-stat-label">Volume</div><div class="txa-stat-value">${v.muted ? 'Muted' : Math.round(v.volume * 100) + '%'}</div></div> | |
| <div class="txa-stat-item full"><div class="txa-stat-label">Player</div><div class="txa-stat-value brand">TXAPlayer ${TXA_VERSION}</div></div> | |
| `; | |
| if (this._lastStatsHTML !== html) { | |
| grid.innerHTML = html; | |
| this._lastStatsHTML = html; | |
| } | |
| } | |
| toggleNativePiP() { document.pictureInPictureElement ? document.exitPictureInPicture() : this.video.requestPictureInPicture(); } | |
| destroy() { | |
| this.destroyed = true; | |
| if (this._devCheck) clearInterval(this._devCheck); | |
| if (this._statsInterval) clearInterval(this._statsInterval); | |
| if (this._autoNextCountdownInterval) clearInterval(this._autoNextCountdownInterval); | |
| // Stop playback & Cleanup Video | |
| if (this.video) { | |
| try { | |
| this.video.ontimeupdate = null; // Unbind immediately | |
| this.video.pause(); | |
| this.video.src = ""; | |
| this.video.load(); | |
| this.video.remove(); | |
| } catch (e) { } | |
| } | |
| // Destroy dashjs | |
| if (this.dashPlayer) { | |
| try { this.dashPlayer.destroy(); } catch (e) { } | |
| this.dashPlayer = null; | |
| } | |
| // Destroy Hls | |
| if (this.hls) { | |
| try { this.hls.destroy(); } catch (e) { } | |
| this.hls = null; | |
| } | |
| // Remove global listeners | |
| if (this._handlers) { | |
| if (this._handlers.winMouseMove) window.removeEventListener('mousemove', this._handlers.winMouseMove); | |
| if (this._handlers.winMouseUp) window.removeEventListener('mouseup', this._handlers.winMouseUp); | |
| if (this._handlers.winKeyDown) window.removeEventListener('keydown', this._handlers.winKeyDown); | |
| if (this._handlers.docClick) document.removeEventListener('click', this._handlers.docClick); | |
| if (this._handlers.docFSChange) document.removeEventListener('fullscreenchange', this._handlers.docFSChange); | |
| if (this._handlers.dragMouseMove) window.removeEventListener('mousemove', this._handlers.dragMouseMove); | |
| if (this._handlers.dragMouseUp) window.removeEventListener('mouseup', this._handlers.dragMouseUp); | |
| if (this._handlers.ctxClick) document.removeEventListener('click', this._handlers.ctxClick); | |
| if (this._handlers.theaterEscape) window.removeEventListener('keydown', this._handlers.theaterEscape); // v5.8.0 | |
| } | |
| if (this.feedbackTimer) clearTimeout(this.feedbackTimer); | |
| if (this.seekDebounce) clearTimeout(this.seekDebounce); | |
| if (this._clickTimer) clearTimeout(this._clickTimer); | |
| if (this._progressSaveInterval) clearInterval(this._progressSaveInterval); // v5.8.0 - Clear progress save interval | |
| // v5.8.0 - Stop ambient light animation | |
| this.stopAmbientLight(); | |
| // v5.8.0 - Save final progress | |
| this.saveWatchProgress(); | |
| // Clear UI | |
| if (this.container) this.container.innerHTML = ""; | |
| console.log("TXAPlayer: Destroyed instance"); | |
| } | |
| detectDevTools() { | |
| // Commented out as requested for development | |
| /* | |
| if (this.options.isAdmin) return; | |
| if (this.detectMobile()) return; | |
| const threshold = 160; | |
| this._devCheck = setInterval(() => { | |
| if (this.destroyed) return; | |
| const start = performance.now(); | |
| debugger; | |
| const end = performance.now(); | |
| if (end - start > threshold) { | |
| this.tamperAlert(true); | |
| } else { | |
| this.tamperAlert(false); | |
| } | |
| }, 2000); | |
| */ | |
| } | |
| tamperAlert(isBlocked) { | |
| const overlay = this.container.querySelector('#txa-tamper'); | |
| if (!overlay || this.isTamperPermanent) return; | |
| if (isBlocked) { | |
| // Once detected, it becomes permanent | |
| this.isTamperPermanent = true; | |
| overlay.classList.remove('txa-hidden'); | |
| console.warn('[TXA] Tamper detected! Locking player.'); | |
| // Aggressively stop video | |
| if (this.video) { | |
| try { | |
| this.video.pause(); | |
| this.video.muted = true; | |
| this.video.removeAttribute('src'); // Remove src attr | |
| this.video.load(); | |
| } catch (e) { } | |
| } | |
| if (this.hls) { | |
| try { | |
| this.hls.stopLoad(); | |
| this.hls.detachMedia(); | |
| this.hls.destroy(); | |
| } catch (e) { } | |
| this.hls = null; | |
| } | |
| if (this.dashPlayer) { | |
| try { | |
| this.dashPlayer.reset(); | |
| this.dashPlayer.destroy(); | |
| } catch (e) { } | |
| this.dashPlayer = null; | |
| } | |
| // Disable interaction | |
| if (this.wrapper) { | |
| this.wrapper.style.pointerEvents = 'none'; | |
| // Remove the opacity 0.1 to keep it "sang lên" | |
| this.wrapper.style.filter = 'grayscale(1) brightness(0.5)'; | |
| } | |
| overlay.style.pointerEvents = 'all'; // Allow clicking reload button | |
| overlay.style.opacity = '1'; | |
| overlay.classList.remove('txa-hidden'); | |
| } else { | |
| // Safe state | |
| overlay.classList.add('txa-hidden'); | |
| if (this.wrapper) { | |
| this.wrapper.style.pointerEvents = ''; | |
| this.wrapper.style.filter = ''; | |
| } | |
| } | |
| } | |
| // ============================================================ | |
| // v5.8.0 NEW FEATURES | |
| // ============================================================ | |
| /** | |
| * Theater Mode - Expand player to full screen overlay (not browser fullscreen) | |
| */ | |
| toggleTheaterMode() { | |
| this.isTheaterMode = !this.isTheaterMode; | |
| const btn = this.container.querySelector('#txa-theater'); | |
| if (this.isTheaterMode) { | |
| this.wrapper.classList.add('theater-mode'); | |
| document.body.classList.add('theater-mode-active'); | |
| if (btn) btn.classList.add('mode-active'); | |
| this.showFeedback('text', 'Theater Mode: On'); | |
| } else { | |
| this.wrapper.classList.remove('theater-mode'); | |
| document.body.classList.remove('theater-mode-active'); | |
| if (btn) btn.classList.remove('mode-active'); | |
| this.showFeedback('text', 'Theater Mode: Off'); | |
| } | |
| } | |
| /** | |
| * Cinema Mode - Ambient light effect that extracts colors from video | |
| */ | |
| toggleCinemaMode() { | |
| this.isCinemaMode = !this.isCinemaMode; | |
| const btn = this.container.querySelector('#txa-cinema'); | |
| if (this.isCinemaMode) { | |
| this.wrapper.classList.add('cinema-mode'); | |
| this.container.classList.add('cinema-active'); | |
| if (btn) btn.classList.add('mode-active'); | |
| this.startAmbientLight(); | |
| this.showFeedback('text', 'Ánh sáng: Bật'); | |
| } else { | |
| this.wrapper.classList.remove('cinema-mode'); | |
| this.container.classList.remove('cinema-active'); | |
| if (btn) btn.classList.remove('mode-active'); | |
| this.stopAmbientLight(); | |
| this.showFeedback('text', 'Ánh sáng: Tắt'); | |
| } | |
| } | |
| startAmbientLight() { | |
| const canvas = this.container.querySelector('#txa-ambient'); | |
| if (!canvas) return; | |
| const ctx = canvas.getContext('2d'); | |
| canvas.width = 32; // Low resolution for blur effect | |
| canvas.height = 18; | |
| this._ambientFrame = () => { | |
| if (!this.isCinemaMode || this.destroyed) return; | |
| if (this.video && !this.video.paused && this.video.readyState >= 2) { | |
| try { | |
| ctx.drawImage(this.video, 0, 0, canvas.width, canvas.height); | |
| } catch (e) { | |
| // Cross-origin video might fail, just ignore | |
| } | |
| } | |
| requestAnimationFrame(this._ambientFrame); | |
| }; | |
| requestAnimationFrame(this._ambientFrame); | |
| } | |
| stopAmbientLight() { | |
| this._ambientFrame = null; | |
| } | |
| /** | |
| * Speed Preview - Long press to fast forward at 2x speed | |
| */ | |
| startSpeedPreview() { | |
| if (this.isSpeedPreview) return; | |
| this.isSpeedPreview = true; | |
| this.originalSpeed = this.video.playbackRate; | |
| this.video.playbackRate = 2; | |
| const indicator = this.container.querySelector('#txa-speed-indicator'); | |
| if (indicator) indicator.classList.add('show'); | |
| if (this.wrapper) this.wrapper.classList.add('hide-controls'); | |
| // Play if paused | |
| if (this.video.paused) { | |
| this._wasPausedBeforePreview = true; | |
| this.video.play().catch(() => { }); | |
| } | |
| } | |
| stopSpeedPreview() { | |
| if (!this.isSpeedPreview) return; | |
| this.isSpeedPreview = false; | |
| this.video.playbackRate = this.originalSpeed || 1; | |
| const indicator = this.container.querySelector('#txa-speed-indicator'); | |
| if (indicator) indicator.classList.remove('show'); | |
| if (this.wrapper) this.wrapper.classList.remove('hide-controls'); | |
| // Pause if was paused before | |
| if (this._wasPausedBeforePreview) { | |
| this.video.pause(); | |
| this._wasPausedBeforePreview = false; | |
| } | |
| } | |
| /** | |
| * Watch Progress Resume - Save and restore watch position | |
| */ | |
| getVideoId() { | |
| // Generate unique ID based on movie ID and episode info for stability | |
| const base = this.options.movieId || this.options.title || 'unknown'; | |
| const ep = this.options.episodeSlug || this.options.episodeName || 'single'; | |
| return 'txa-progress-' + this.hashCode(base + '-' + ep); | |
| } | |
| hashCode(str) { | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) { | |
| const char = str.charCodeAt(i); | |
| hash = ((hash << 5) - hash) + char; | |
| hash = hash & hash; | |
| } | |
| return Math.abs(hash).toString(36); | |
| } | |
| saveWatchProgress(useApi = false) { | |
| if (!this.video || this.video.duration < 10) return; | |
| const ct = this.video.currentTime; | |
| const dur = this.video.duration; | |
| const opt = this.options; | |
| // 1. App LocalStorage Sync (ContinueWatching) | |
| if (window.ContinueWatching && opt.movieSlug) { | |
| window.ContinueWatching.save( | |
| opt.movieSlug, | |
| opt.title.split(' - ')[0], // Assume Title is "Movie - Episode" | |
| opt.movieThumb, | |
| opt.episodeSlug, | |
| opt.title.split(' - ')[1]?.replace('Tập ', '') || '1', | |
| ct, | |
| dur, | |
| opt.epCount || 1 | |
| ); | |
| } | |
| // 2. App API Sync (Database) | |
| if (useApi && opt.saveUrl) { | |
| fetch(opt.saveUrl, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(opt.csrfToken ? { 'X-CSRF-TOKEN': opt.csrfToken } : {}) | |
| }, | |
| body: JSON.stringify({ | |
| movieSlug: opt.movieSlug, | |
| movieName: opt.title?.split(' - ')[0], | |
| movieThumb: opt.movieThumb, | |
| episodeName: opt.episodeName, | |
| currentTime: ct, | |
| duration: dur, | |
| // Legacy support | |
| movie_id: opt.movieId, | |
| episode_id: opt.episodeId, | |
| current_time: ct | |
| }) | |
| }).catch(e => console.warn('Player: Progress sync failed', e)); | |
| } | |
| // 3. Player Internal Backup (Fallback) | |
| if (ct > 10 && ct < dur - 30) { | |
| localStorage.setItem(this.getVideoId(), JSON.stringify({ | |
| time: ct, | |
| duration: dur, | |
| timestamp: Date.now() | |
| })); | |
| } | |
| } | |
| async checkWatchResume() { | |
| if (this.resumeShown) return; | |
| const opt = this.options; | |
| let resumeTime = 0; | |
| let duration = 0; | |
| // Try API first (Best source of truth) | |
| if (opt.restoreUrl && opt.movieId) { | |
| try { | |
| const res = await fetch(opt.restoreUrl); | |
| const data = await res.json(); | |
| if (data && data.current_time > 10) { | |
| // Check if same episode (ID or Name/Slug match for multi-server) | |
| // Relaxed Match: If restoreUrl is specific, we trust the data | |
| const epIdMatch = (data.episode_id == opt.episodeId); | |
| // Normalize "Tập 01" -> "1", "01" -> "1" | |
| const norm = (s) => (s || '').toString().toLowerCase().replace('tập ', '').replace(/^0+/, '').trim(); | |
| const epNameMatch = norm(data.episode_name) === norm(opt.episodeName); | |
| // Slug match is usually safer but relies on consistent slugs | |
| const slugMatch = (data.episode_slug && opt.episodeSlug && data.episode_slug === opt.episodeSlug); | |
| if (epIdMatch || epNameMatch || slugMatch || opt.restoreUrl.includes(opt.episodeId)) { | |
| resumeTime = data.current_time; | |
| duration = data.duration || 0; | |
| } | |
| } | |
| } catch (e) { console.warn('Player: Restore API failed', e); } | |
| } | |
| // Fallback to Player's internal LocalStorage | |
| if (!resumeTime) { | |
| const saved = localStorage.getItem(this.getVideoId()); | |
| if (saved) { | |
| try { | |
| const data = JSON.parse(saved); | |
| // Check if valid timestamp (within 30 days) | |
| if (Date.now() - data.timestamp < 30 * 24 * 60 * 60 * 1000) { | |
| resumeTime = data.time; | |
| duration = data.duration; | |
| } | |
| } catch (e) { } | |
| } | |
| } | |
| if (resumeTime > 15) { | |
| this.resumeShown = true; | |
| const timeStr = this.formatTime(resumeTime, duration >= 3600); | |
| // MOBILE: Use SweetAlert2 (Premium & Out-of-player) | |
| // Ensure Swal is available, otherwise fallback to standard overlay | |
| if (this.detectMobile() && typeof Swal !== 'undefined') { | |
| Swal.fire({ | |
| title: 'Tiếp tục xem?', | |
| text: `Hệ thống ghi nhận bạn đã xem đến ${timeStr}. Bạn có muốn tiếp tục từ đây không ? `, | |
| icon: 'question', | |
| showCancelButton: true, | |
| confirmButtonText: 'Có, tiếp tục', | |
| cancelButtonText: 'Xem từ đầu', | |
| background: '#141414', | |
| color: '#fff', | |
| confirmButtonColor: '#8b5cf6', | |
| cancelButtonColor: '#334155', | |
| heightAuto: false, | |
| backdrop: `rgba(0, 0, 0, 0.6)`, | |
| allowOutsideClick: false // Prevent accidental close | |
| }).then((result) => { | |
| if (result.isConfirmed) { | |
| this.safeSeek(resumeTime); | |
| this.video.play().catch(() => { }); | |
| this.showFeedback('text', `Đã tiếp tục từ ${timeStr} `); | |
| } else if (result.dismiss === Swal.DismissReason.cancel) { | |
| localStorage.removeItem(this.getVideoId()); | |
| // Don't auto play if cancelled, let user decide | |
| } | |
| }); | |
| return; | |
| } | |
| // DESKTOP or Mobile-Fallback: Classic Overlay | |
| const overlay = this.container.querySelector('#txa-resume'); | |
| const timeEl = this.container.querySelector('#txa-resume-time'); | |
| if (overlay && timeEl) { | |
| timeEl.textContent = timeStr; | |
| // Ensure overlay is visible | |
| overlay.style.display = 'flex'; | |
| // Trigger animation | |
| requestAnimationFrame(() => { | |
| overlay.classList.add('show'); | |
| }); | |
| // Auto-hide after 30s if no interaction (increased from 15s) | |
| const autoHideTimer = setTimeout(() => { | |
| if (overlay.classList.contains('show')) { | |
| overlay.classList.remove('show'); | |
| setTimeout(() => overlay.style.display = 'none', 500); | |
| } | |
| }, 30000); | |
| const handleYes = (e) => { | |
| if (e) e.stopPropagation(); | |
| clearTimeout(autoHideTimer); | |
| this.safeSeek(resumeTime); | |
| overlay.classList.remove('show'); | |
| setTimeout(() => overlay.style.display = 'none', 500); | |
| this.video.play().catch(() => { }); | |
| this.showFeedback('text', `Đã tiếp tục từ ${timeStr} `); | |
| }; | |
| const handleNo = (e) => { | |
| if (e) e.stopPropagation(); | |
| clearTimeout(autoHideTimer); | |
| overlay.classList.remove('show'); | |
| setTimeout(() => overlay.style.display = 'none', 500); | |
| localStorage.removeItem(this.getVideoId()); | |
| }; | |
| const yesBtn = overlay.querySelector('#txa-resume-yes'); | |
| const noBtn = overlay.querySelector('#txa-resume-no'); | |
| if (yesBtn) { | |
| yesBtn.onclick = handleYes; | |
| yesBtn.ontouchstart = (e) => { e.preventDefault(); handleYes(e); }; | |
| } | |
| if (noBtn) { | |
| noBtn.onclick = handleNo; | |
| noBtn.ontouchstart = (e) => { e.preventDefault(); handleNo(e); }; | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Double-tap Ripple Effect - Visual feedback for double-tap seek | |
| */ | |
| createRipple(x, y) { | |
| const ripple = document.createElement('div'); | |
| ripple.className = 'txa-ripple'; | |
| ripple.style.left = (x - 50) + 'px'; | |
| ripple.style.top = (y - 50) + 'px'; | |
| this.wrapper.appendChild(ripple); | |
| // Remove after animation | |
| setTimeout(() => ripple.remove(), 600); | |
| } | |
| /** | |
| * Setup new feature event bindings | |
| */ | |
| setupNewFeatures() { | |
| const el = (s) => this.container.querySelector(s); | |
| // Theater Mode Button | |
| const theaterBtn = el('#txa-theater'); | |
| if (theaterBtn) { | |
| theaterBtn.onclick = () => this.toggleTheaterMode(); | |
| } | |
| // Cinema Mode Button | |
| const cinemaBtn = el('#txa-cinema'); | |
| if (cinemaBtn) { | |
| cinemaBtn.onclick = () => this.toggleCinemaMode(); | |
| } | |
| // Speed Preview - Long press on video | |
| let speedTimeout = null; | |
| const startSpeed = () => { | |
| speedTimeout = setTimeout(() => { | |
| this.startSpeedPreview(); | |
| }, 500); | |
| }; | |
| const endSpeed = () => { | |
| clearTimeout(speedTimeout); | |
| this.stopSpeedPreview(); | |
| }; | |
| if (this.detectMobile()) { | |
| this.video.addEventListener('mousedown', startSpeed); | |
| this.video.addEventListener('mouseup', endSpeed); | |
| this.video.addEventListener('mouseleave', endSpeed); | |
| } | |
| // Touch support for Speed Preview | |
| this.video.addEventListener('touchstart', startSpeed, { passive: true }); | |
| this.video.addEventListener('touchend', endSpeed); | |
| this.video.addEventListener('touchcancel', endSpeed); | |
| // Watch Resume - Check on video ready | |
| this.video.addEventListener('loadedmetadata', () => { | |
| this.checkWatchResume(); | |
| }); | |
| // Save progress periodically (LocalStorage: 10s, API: 30s) | |
| let lastApiSave = Date.now(); | |
| this._progressSaveInterval = setInterval(() => { | |
| if (!this.destroyed && this.video && !this.video.paused) { | |
| const now = Date.now(); | |
| const useApi = (now - lastApiSave) >= 30000; | |
| this.saveWatchProgress(useApi); | |
| if (useApi) lastApiSave = now; | |
| } | |
| }, 10000); | |
| // Save on pause and before unload | |
| this.video.addEventListener('pause', () => this.saveWatchProgress(true)); | |
| window.addEventListener('beforeunload', () => this.saveWatchProgress(true)); | |
| // Ripple effect on double-tap zones | |
| const addRippleToZone = (zone, seekFn) => { | |
| if (!zone) return; | |
| zone.addEventListener('dblclick', (e) => { | |
| const rect = this.wrapper.getBoundingClientRect(); | |
| this.createRipple(e.clientX - rect.left, e.clientY - rect.top); | |
| }); | |
| }; | |
| addRippleToZone(el('#txa-zone-l')); | |
| addRippleToZone(el('#txa-zone-r')); | |
| // Escape key to exit Theater Mode | |
| this._handlers.theaterEscape = (e) => { | |
| if (e.key === 'Escape' && this.isTheaterMode) { | |
| this.toggleTheaterMode(); | |
| } | |
| }; | |
| window.addEventListener('keydown', this._handlers.theaterEscape); | |
| // v6.3.0 - Screen Width Warning (<100px) | |
| const checkWidth = () => { | |
| if (window.innerWidth < 100 && !window._txa_too_small) { | |
| window._txa_too_small = true; | |
| // Hide header/nav via CSS first just in case document.write is blocked or delayed | |
| const header = document.querySelector('.header'); | |
| if (header) header.style.display = 'none'; | |
| document.open(); | |
| document.write(` | |
| < html > | |
| <head> | |
| <title>TPhimX - Màn hình quá nhỏ</title> | |
| <style> | |
| body { background:#0a0a0a; color:#fff; display:flex; align-items:center; justify-content:center; height:100vh; margin:0; font-family:'Outfit', sans-serif; text-align:center; padding:20px; } | |
| .box { background:rgba(255,255,255,0.03); border:1px solid rgba(255,255,255,0.1); border-radius:24px; padding:30px; backdrop-filter:blur(20px); max-width:400px; box-shadow:0 20px 50px rgba(0,0,0,0.5); } | |
| h1 { color:#ef4444; font-size:20px; font-weight:900; margin-bottom:15px; } | |
| p { opacity:0.7; font-size:14px; line-height:1.6; } | |
| .logo { font-size:24px; font-weight:800; margin-bottom:20px; } | |
| .logo .t { color:#8b5cf6; } | |
| .logo .phim { color:#fff; } | |
| .logo .x { color:#ef4444; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="box"> | |
| <div class="logo"><span class="t">T</span><span class="phim">PHIM</span><span class="x">X</span></div> | |
| <h1>MÀN HÌNH QUÁ NHỎ</h1> | |
| <p>Vui lòng phóng to trình duyệt hoặc xoay ngang điện thoại để có trải nghiệm xem phim tốt nhất tại TPhimX.</p> | |
| </div> | |
| <script> | |
| window.addEventListener('resize', () => { | |
| if (window.innerWidth >= 100) { | |
| location.reload(); | |
| } | |
| }); | |
| <\/script> | |
| </body> | |
| </html > | |
| `); | |
| document.close(); | |
| } | |
| }; | |
| window.addEventListener('resize', checkWidth); | |
| checkWidth(); // Initial check | |
| } | |
| checkTicker() { | |
| if (!this.video || this.video.paused) return; | |
| const cur = this.video.currentTime; | |
| const dur = this.video.duration; | |
| // ENDING SLOT: 1 minute before end (60s) - Only for videos > 2 mins | |
| if (dur > 120 && cur >= dur - 60 && cur < dur - 10) { | |
| if (!this._endingTickerShown) { | |
| console.log('[TXAPlayer] Triggering Ending Ticker'); | |
| if (this._isTickerRunning) { | |
| const container = this.container.querySelector('#txa-ticker'); | |
| const text = this.container.querySelector('#txa-ticker-text'); | |
| if (container && text) { | |
| text.classList.remove('run'); | |
| void text.offsetWidth; | |
| container.classList.remove('active'); | |
| } | |
| } | |
| this.triggerTicker(45, 'ending'); | |
| this._endingTickerShown = true; | |
| } | |
| return; | |
| } | |
| // FIXED SLOT: 3:20 (200s) - Duration 40s | |
| if (cur >= 200 && cur < 240) { | |
| if (!this._fixedTickerShown) { | |
| console.log('[TXAPlayer] Triggering Fixed Slot Ticker (3:20)'); | |
| if (this._isTickerRunning) { | |
| const container = this.container.querySelector('#txa-ticker'); | |
| const text = this.container.querySelector('#txa-ticker-text'); | |
| if (container && text) { | |
| text.classList.remove('run'); | |
| void text.offsetWidth; | |
| container.classList.remove('active'); | |
| } | |
| } | |
| this.triggerTicker(40, 'fixed'); | |
| this._fixedTickerShown = true; | |
| } | |
| return; | |
| } | |
| // Avoid random ticker if approaching fixed slot (e.g., 160s-200s buffer) | |
| if (cur >= 160 && cur < 240) return; | |
| // Avoid random ticker near end slot (80s buffer) | |
| if (dur > 0 && cur >= dur - 80) return; | |
| // Reset flags if user seeks back | |
| if (cur < 200 && this._fixedTickerShown) this._fixedTickerShown = false; | |
| if (dur > 0 && cur < dur - 100 && this._endingTickerShown) this._endingTickerShown = false; | |
| // Random Ticker Logic | |
| if (this.tickerCount >= this.tickerMax) return; | |
| if (cur > this.tickerNextTime && !this._isTickerRunning) { | |
| console.log('[TXAPlayer] Triggering Random Ticker at', cur); | |
| this.triggerTicker(); | |
| } | |
| } | |
| triggerTicker(forcedDuration = null, type = 'random') { | |
| const container = this.container.querySelector('#txa-ticker'); | |
| const text = this.container.querySelector('#txa-ticker-text'); | |
| if (!container || !text) { | |
| console.warn('[TXAPlayer] Ticker container OR text not found!'); | |
| return; | |
| } | |
| this._isTickerRunning = true; | |
| if (type === 'random') this.tickerCount++; | |
| // Random Style: 1-4 | |
| const styles = ['style-1', 'style-2', 'style-3', 'style-4']; | |
| text.className = 'txa-ticker-text ' + styles[Math.floor(Math.random() * styles.length)]; | |
| // Content | |
| const domain = window.location.hostname.replace('www.', ''); | |
| const randomMsgs = [ | |
| `Bạn đang thưởng thức phim tại <span class="highlight">${domain}</span> | Bản quyền thuộc về <span class="brand">TPHIMX</span>`, | |
| `Cảm ơn bạn đã lựa chọn <span class="highlight">${domain}</span> | Chúc bạn xem phim vui vẻ!`, | |
| `Hệ thống <span class="brand">TPHIMX</span> đang tối ưu hóa trải nghiệm của bạn tại <span class="highlight">${domain}</span>`, | |
| `Đừng quên lưu <span class="highlight">${domain}</span> vào dấu trang để truy cập nhanh hơn!`, | |
| `Phim chất lượng cao, tốc độ mượt mà chỉ có tại <span class="brand">TPHIMX</span> & <span class="highlight">${domain}</span>`, | |
| `Nếu link phim lỗi, hãy thử đổi Server hoặc báo lỗi ngay cho admin bằng nút ở ngoài nhé!` | |
| ]; | |
| // Type selection | |
| if (type === 'random') { | |
| text.innerHTML = randomMsgs[Math.floor(Math.random() * randomMsgs.length)]; | |
| } else if (type === 'ending') { | |
| const filmTitle = this.options.title || 'Phim'; | |
| text.innerHTML = `<span style="opacity:0.8;font-weight:400;">BẠN đang xem</span> <span class="highlight">${filmTitle}</span> <span style="opacity:0.8;font-weight:400;margin-left:5px;">tại</span> <span style="color:#8b5cf6;font-weight:900;margin-left:5px;">T</span><span style="color:#fff;font-weight:900;">PHIM</span><span style="color:#facc15;font-weight:900;">X</span> <span style="opacity:0.3;font-size:0.9em;margin:0 5px;">|</span> <span style="opacity:0.8;font-weight:400;">Chúc bạn có những giây phút thư giãn tuyệt vời tại</span> <span class="highlight">${domain}</span>`; | |
| } else { | |
| const icon = ['fa-film', 'fa-star', 'fa-heart', 'fa-crown'][Math.floor(Math.random() * 4)]; | |
| const iconColor = ['#fbbf24', '#f472b6', '#a78bfa', '#38bdf8'][Math.floor(Math.random() * 4)]; | |
| text.innerHTML = `<i class="fas ${icon}" style="color:${iconColor}"></i> <span style="opacity:0.8;font-weight:400;">Bạn đang thưởng thức phim tại</span> <span class="highlight">${domain}</span> <span style="opacity:0.3;font-size:0.9em;margin:0 5px;">|</span> <span style="opacity:0.8;font-weight:400;">Bản quyền thuộc về</span> <span class="brand">TPHIMX</span>`; | |
| } | |
| // Duration Logic: Fix space issue | |
| const duration = forcedDuration || (Math.floor(Math.random() * (34 - 20 + 1)) + 20); | |
| text.style.setProperty('--ticker-dur', `${duration}s`); | |
| container.classList.add('active'); | |
| text.classList.remove('run'); | |
| void text.offsetWidth; // Trigger reflow | |
| text.classList.add('run'); | |
| // Reset on end | |
| const onEnd = () => { | |
| container.classList.remove('active'); | |
| text.classList.remove('run'); | |
| this._isTickerRunning = false; | |
| this.lastTickerTime = this.video.currentTime; | |
| this.tickerNextTime = this.lastTickerTime + 60 + Math.random() * 60; | |
| text.removeEventListener('animationend', onEnd); | |
| }; | |
| text.addEventListener('animationend', onEnd); | |
| } | |
| // --- v6.4.0 Real-time Clock Logic --- | |
| startClock() { | |
| if (this._clockInterval) clearInterval(this._clockInterval); | |
| this.updateClock(); | |
| this._clockInterval = setInterval(() => this.updateClock(), 1000); | |
| } | |
| updateClockVisibility() { | |
| if (!this.container || this.destroyed) return; | |
| const clockEl = this.container.querySelector('#txa-clock'); | |
| const itemClock = this.container.querySelector('#txa-item-clock'); | |
| const isMobile = this.detectMobile(); | |
| // v6.4.1 - Expanded fullscreen detection for iOS & Android | |
| const isFS = !!( | |
| document.fullscreenElement || | |
| document.webkitFullscreenElement || | |
| document.mozFullScreenElement || | |
| document.msFullscreenElement || | |
| document.webkitIsFullScreen || | |
| this.video?.webkitDisplayingFullscreen | |
| ); | |
| const isFSClass = this.wrapper?.classList.contains('fullscreen-mode'); | |
| const enabled = this.settings.showRealTime; | |
| // v6.4.1 - Logic Change: Always show if enabled, regardless of login (for easier verification) | |
| // You can restore isLogged requirement later if requested. | |
| // Mobile visibility: Show if Fullscreen OR Landscape (common for watching) | |
| const isLandscape = isMobile && window.innerWidth > window.innerHeight; | |
| let isVisible = enabled; | |
| if (isMobile && !isFS && !isFSClass && !isLandscape) isVisible = false; | |
| if (clockEl) clockEl.classList.toggle('active', isVisible); | |
| if (itemClock) itemClock.style.display = enabled ? 'flex' : 'none'; | |
| return isVisible; | |
| } | |
| updateClock() { | |
| const clockText = this.container.querySelector('#txa-clock-text'); | |
| if (!clockText || (this.settings.showRealTime === false)) return; | |
| const now = new Date(); | |
| const HH = String(now.getHours()).padStart(2, '0'); | |
| const ii = String(now.getMinutes()).padStart(2, '0'); | |
| const ss = String(now.getSeconds()).padStart(2, '0'); | |
| // Fast path for default format to improve performance | |
| if (this.settings.clockFormat === 'H:i:s' || !this.settings.clockFormat) { | |
| clockText.textContent = `${HH}:${ii}:${ss} `; | |
| return; | |
| } | |
| const dd = String(now.getDate()).padStart(2, '0'); | |
| const mm = String(now.getMonth() + 1).padStart(2, '0'); | |
| const yyyy = now.getFullYear(); | |
| const yy = String(yyyy).slice(-2); | |
| clockText.textContent = this.settings.clockFormat | |
| .replace('H', HH).replace('i', ii).replace(/s|S/g, ss) | |
| .replace('d', dd).replace('M', mm).replace('YYYY', yyyy).replace('YY', yy); | |
| } | |
| toggleTheaterMode() { | |
| this.isTheaterMode = !this.isTheaterMode; | |
| const wrapper = document.getElementById('player-wrapper'); | |
| const overlay = document.getElementById('theater-overlay'); | |
| if (this.isTheaterMode) { | |
| if (wrapper) wrapper.style.zIndex = '50'; | |
| if (overlay) { | |
| overlay.style.opacity = '1'; | |
| overlay.style.pointerEvents = 'auto'; | |
| } | |
| document.body.style.overflow = 'hidden'; | |
| } else { | |
| if (wrapper) wrapper.style.zIndex = ''; | |
| if (overlay) { | |
| overlay.style.opacity = '0'; | |
| overlay.style.pointerEvents = 'none'; | |
| } | |
| document.body.style.overflow = ''; | |
| } | |
| this.showFeedback('text', this.isTheaterMode ? 'Chế độ Rạp: Bật' : 'Chế độ Rạp: Tắt'); | |
| } | |
| destroy() { | |
| if (this.destroyed) return; | |
| this.destroyed = true; | |
| if (this._clockInterval) clearInterval(this._clockInterval); | |
| if (this._uiTimer) clearTimeout(this._uiTimer); | |
| if (this._autoNextCountdownInterval) clearInterval(this._autoNextCountdownInterval); | |
| // Cleanup global event listeners | |
| window.removeEventListener('mousemove', this._handlers.winMouseMove); | |
| window.removeEventListener('mouseup', this._handlers.winMouseUp); | |
| document.removeEventListener('fullscreenchange', this._handlers.docFSChange); | |
| document.removeEventListener('click', this._handlers.docClick); | |
| if (this.dashPlayer) { | |
| this.dashPlayer.destroy(); | |
| } | |
| if (this.hls) { | |
| this.hls.destroy(); | |
| } | |
| } | |
| }; | |
| } | |