dptxa-proxy / public /lib /txa /txaplayer.js
TXAVLOG
Deploy DPTXA to Hugging Face Spaces
4bea261
Raw
History Blame Contribute Delete
344 kB
/**
* 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();
}
}
};
}