Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
626 lines
24 KiB
JavaScript
626 lines
24 KiB
JavaScript
// HLS Video Player with statistics and sharing
|
|
class VideoPlayer {
|
|
constructor() {
|
|
this.hls = null;
|
|
this.video = null;
|
|
this.videoHash = null;
|
|
this.videoName = null;
|
|
this.stats = {
|
|
startTime: Date.now(),
|
|
bytesLoaded: 0,
|
|
droppedFrames: 0,
|
|
lastBytesLoaded: 0,
|
|
lastTime: Date.now()
|
|
};
|
|
|
|
this.initializeFromURL();
|
|
this.initializePlayer();
|
|
this.initializeTheme();
|
|
this.setupEventListeners();
|
|
|
|
// Update stats every second
|
|
setInterval(() => this.updatePlaybackStats(), 1000);
|
|
}
|
|
|
|
initializeFromURL() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
this.videoHash = urlParams.get('hash');
|
|
this.videoName = urlParams.get('name') || 'Unknown Video';
|
|
|
|
if (!this.videoHash) {
|
|
this.showError('No video hash provided in URL');
|
|
return;
|
|
}
|
|
|
|
document.getElementById('video-title').textContent = this.videoName;
|
|
|
|
// Initialize hash display immediately
|
|
if (this.videoHash) {
|
|
document.getElementById('video-hash').textContent = this.videoHash.substring(0, 8) + '...';
|
|
document.getElementById('video-hash').title = this.videoHash;
|
|
}
|
|
|
|
this.setupShareLinks();
|
|
}
|
|
|
|
initializePlayer() {
|
|
this.video = document.getElementById('video-player');
|
|
|
|
if (!this.videoHash) return;
|
|
|
|
// Check if this is an MKV file - don't attempt browser playback
|
|
const isMKV = this.videoName && this.videoName.toLowerCase().endsWith('.mkv');
|
|
|
|
if (isMKV) {
|
|
console.log('MKV file detected - showing download options instead of browser playback');
|
|
this.showMKVDownloadInterface();
|
|
return;
|
|
}
|
|
|
|
// Use direct streaming for non-MKV files
|
|
console.log('Initializing direct video streaming');
|
|
this.initializeDirectStreaming();
|
|
}
|
|
|
|
initializeDirectStreaming() {
|
|
const directUrl = `/api/stream/${this.videoHash}`;
|
|
this.video.src = directUrl;
|
|
|
|
// Add event listeners for direct streaming
|
|
this.video.addEventListener('loadedmetadata', () => {
|
|
console.log('Video metadata loaded');
|
|
this.updateVideoInfo();
|
|
});
|
|
|
|
this.video.addEventListener('canplay', () => {
|
|
console.log('Video can start playing');
|
|
this.updateVideoInfo();
|
|
});
|
|
|
|
this.video.addEventListener('error', (e) => {
|
|
console.error('Video error:', e, this.video.error);
|
|
this.handleVideoError();
|
|
});
|
|
|
|
this.video.addEventListener('progress', () => {
|
|
this.updateBufferInfo();
|
|
this.updateNetworkStats();
|
|
});
|
|
|
|
// Load the video
|
|
this.video.load();
|
|
}
|
|
|
|
handleVideoError() {
|
|
const error = this.video.error;
|
|
let errorMessage = 'Video playback failed';
|
|
let showExternalPlayerOption = false;
|
|
|
|
// Check if this is an MKV file
|
|
const isMKV = this.videoName && this.videoName.toLowerCase().endsWith('.mkv');
|
|
|
|
if (error) {
|
|
switch (error.code) {
|
|
case error.MEDIA_ERR_ABORTED:
|
|
errorMessage = 'Video playback was aborted';
|
|
break;
|
|
case error.MEDIA_ERR_NETWORK:
|
|
errorMessage = 'Network error occurred while loading video';
|
|
break;
|
|
case error.MEDIA_ERR_DECODE:
|
|
if (isMKV) {
|
|
errorMessage = 'MKV files are not supported in web browsers';
|
|
showExternalPlayerOption = true;
|
|
} else {
|
|
errorMessage = 'Video format is not supported or corrupted';
|
|
}
|
|
break;
|
|
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
|
if (isMKV) {
|
|
errorMessage = 'MKV files require external video players';
|
|
showExternalPlayerOption = true;
|
|
} else {
|
|
errorMessage = 'Video source is not supported';
|
|
}
|
|
break;
|
|
default:
|
|
errorMessage = `Unknown video error (code: ${error.code})`;
|
|
if (isMKV) {
|
|
showExternalPlayerOption = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.showError(errorMessage, showExternalPlayerOption);
|
|
}
|
|
|
|
showMKVDownloadInterface() {
|
|
const videoContainer = document.querySelector('.video-container');
|
|
|
|
videoContainer.innerHTML = `
|
|
<div style="display: flex; align-items: center; justify-content: center;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 400px; border-radius: 12px; color: white;">
|
|
<div style="text-align: center; max-width: 600px; padding: 30px;">
|
|
<div style="font-size: 4rem; margin-bottom: 20px;">🎬</div>
|
|
<h2 style="margin-bottom: 20px; font-size: 1.8rem;">MKV File Detected</h2>
|
|
<p style="margin-bottom: 25px; font-size: 1.1rem; line-height: 1.6; opacity: 0.9;">
|
|
<strong>Browser Compatibility Notice:</strong><br>
|
|
MKV files cannot be played directly in web browsers due to codec limitations.
|
|
Both Firefox and Chrome have limited or no support for the Matroska container format.
|
|
</p>
|
|
|
|
<div style="background-color: rgba(255,255,255,0.15); padding: 20px; border-radius: 8px; margin: 25px 0; text-align: left;">
|
|
<h4 style="margin-bottom: 10px;">🔧 Technical Details:</h4>
|
|
<ul style="margin: 0; padding-left: 20px; opacity: 0.9;">
|
|
<li><strong>Firefox:</strong> No native MKV support</li>
|
|
<li><strong>Chrome:</strong> Partial support, often audio issues</li>
|
|
<li><strong>Codec:</strong> Your file likely uses DDP5.1 audio</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<h3 style="margin-bottom: 20px;">📥 Available Options:</h3>
|
|
<div style="display: flex; gap: 15px; justify-content: center; flex-wrap: wrap; margin-bottom: 25px;">
|
|
<button onclick="downloadVideo()" class="action-btn"
|
|
style="background-color: #28a745; color: white; padding: 12px 24px; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
|
|
📥 Download File
|
|
</button>
|
|
<button onclick="copyVLCURL()" class="action-btn"
|
|
style="background-color: #ff8c00; color: white; padding: 12px 24px; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
|
|
🎬 Copy VLC URL
|
|
</button>
|
|
<button onclick="getTorrent()" class="action-btn"
|
|
style="background-color: #6f42c1; color: white; padding: 12px 24px; border: none; border-radius: 6px; font-size: 1rem; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
|
|
🧲 Get Torrent
|
|
</button>
|
|
</div>
|
|
|
|
<div style="background-color: rgba(255,255,255,0.1); padding: 15px; border-radius: 8px; font-size: 0.95rem;">
|
|
💡 <strong>Recommended:</strong> Use VLC Media Player, MPV, or similar desktop players for best MKV playback experience.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Hide video controls and quality selector since we're not using video element
|
|
this.setupQualitySelector();
|
|
}
|
|
|
|
updateBufferInfo() {
|
|
if (this.video.buffered.length > 0) {
|
|
const bufferedEnd = this.video.buffered.end(this.video.buffered.length - 1);
|
|
const bufferHealth = Math.max(0, bufferedEnd - this.video.currentTime);
|
|
document.getElementById('buffer-health').textContent = `${bufferHealth.toFixed(1)}s`;
|
|
}
|
|
}
|
|
|
|
initializeTheme() {
|
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Video events
|
|
this.video.addEventListener('loadstart', () => {
|
|
console.log('Video load started');
|
|
});
|
|
|
|
this.video.addEventListener('loadedmetadata', () => {
|
|
this.updateVideoInfo();
|
|
});
|
|
|
|
this.video.addEventListener('play', () => {
|
|
console.log('Video playback started');
|
|
});
|
|
|
|
this.video.addEventListener('error', (e) => {
|
|
console.error('Video error:', e);
|
|
this.showError('Video playback error');
|
|
});
|
|
|
|
// Quality selector
|
|
const qualitySelect = document.getElementById('quality-select');
|
|
qualitySelect.addEventListener('change', (e) => {
|
|
this.changeQuality(e.target.value);
|
|
});
|
|
}
|
|
|
|
setupQualitySelector() {
|
|
// Hide quality selector for direct streaming as we serve native quality
|
|
document.getElementById('quality-selector').classList.add('hidden');
|
|
}
|
|
|
|
changeQuality(qualityIndex) {
|
|
if (!this.hls) return;
|
|
|
|
if (qualityIndex === 'auto') {
|
|
this.hls.currentLevel = -1; // Auto quality
|
|
} else {
|
|
this.hls.currentLevel = parseInt(qualityIndex);
|
|
}
|
|
|
|
this.updateCurrentQuality();
|
|
}
|
|
|
|
updateVideoInfo() {
|
|
// Update video metadata display - show first 8 chars + ellipsis
|
|
if (this.videoHash) {
|
|
document.getElementById('video-hash').textContent = this.videoHash.substring(0, 8) + '...';
|
|
document.getElementById('video-hash').title = this.videoHash; // Full hash on hover
|
|
}
|
|
|
|
if (this.video.duration && isFinite(this.video.duration)) {
|
|
document.getElementById('video-duration').textContent = this.formatTime(this.video.duration);
|
|
}
|
|
|
|
// Get file size from metadata
|
|
this.fetchVideoMetadata();
|
|
}
|
|
|
|
async fetchVideoMetadata() {
|
|
try {
|
|
// Try to get metadata from the gateway API
|
|
const response = await fetch(`/api/info/${this.videoHash}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('Video metadata:', data);
|
|
|
|
if (data.size) {
|
|
this.videoSize = data.size;
|
|
document.getElementById('video-size').textContent = this.formatBytes(data.size);
|
|
}
|
|
|
|
// Update video title with actual filename if available
|
|
if (data.name && data.name !== 'Unknown Video') {
|
|
document.getElementById('video-title').textContent = data.name;
|
|
this.videoName = data.name;
|
|
}
|
|
|
|
// Update duration from metadata if video element doesn't have it
|
|
if (data.duration && (!this.video.duration || isNaN(this.video.duration))) {
|
|
document.getElementById('video-duration').textContent = this.formatTime(data.duration);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not fetch video metadata:', error);
|
|
}
|
|
}
|
|
|
|
updatePlaybackStats() {
|
|
if (!this.video) return;
|
|
|
|
// Update current quality
|
|
this.updateCurrentQuality();
|
|
|
|
// Update buffer health
|
|
if (this.video.buffered.length > 0) {
|
|
const bufferedEnd = this.video.buffered.end(this.video.buffered.length - 1);
|
|
const bufferHealth = Math.max(0, bufferedEnd - this.video.currentTime);
|
|
document.getElementById('buffer-health').textContent = `${bufferHealth.toFixed(1)}s`;
|
|
}
|
|
|
|
// Update dropped frames (if available)
|
|
if (this.video.getVideoPlaybackQuality) {
|
|
const quality = this.video.getVideoPlaybackQuality();
|
|
document.getElementById('dropped-frames').textContent = quality.droppedVideoFrames || 0;
|
|
}
|
|
}
|
|
|
|
updateCurrentQuality() {
|
|
// For direct streaming, show the native video quality if available
|
|
if (this.video.videoWidth && this.video.videoHeight) {
|
|
document.getElementById('current-quality').textContent = `${this.video.videoHeight}p (Native)`;
|
|
} else {
|
|
document.getElementById('current-quality').textContent = 'Loading...';
|
|
}
|
|
}
|
|
|
|
updateNetworkStats() {
|
|
if (!this.video.buffered.length) return;
|
|
|
|
const currentTime = Date.now();
|
|
const elapsed = (currentTime - this.stats.lastTime) / 1000;
|
|
|
|
if (elapsed > 1) { // Update every second
|
|
// Estimate bytes loaded from buffer
|
|
const bufferedBytes = this.estimateBufferedBytes();
|
|
const bytesDiff = bufferedBytes - this.stats.lastBytesLoaded;
|
|
|
|
if (bytesDiff > 0 && elapsed > 0) {
|
|
const speed = bytesDiff / elapsed;
|
|
document.getElementById('network-speed').textContent = `${this.formatBytes(speed)}/s`;
|
|
}
|
|
|
|
this.stats.lastBytesLoaded = bufferedBytes;
|
|
this.stats.lastTime = currentTime;
|
|
}
|
|
}
|
|
|
|
estimateBufferedBytes() {
|
|
if (!this.video.buffered.length || !this.video.duration) return 0;
|
|
|
|
let totalBuffered = 0;
|
|
for (let i = 0; i < this.video.buffered.length; i++) {
|
|
totalBuffered += this.video.buffered.end(i) - this.video.buffered.start(i);
|
|
}
|
|
|
|
// Estimate bytes based on duration ratio (rough approximation)
|
|
const bufferedRatio = totalBuffered / this.video.duration;
|
|
return bufferedRatio * (this.videoSize || 0);
|
|
}
|
|
|
|
setupShareLinks() {
|
|
if (!this.videoHash) return;
|
|
|
|
const baseUrl = window.location.origin;
|
|
|
|
document.getElementById('direct-link').value = `${baseUrl}/player.html?hash=${this.videoHash}&name=${encodeURIComponent(this.videoName)}`;
|
|
document.getElementById('hls-link').value = `${baseUrl}/api/stream/${this.videoHash}/playlist.m3u8`;
|
|
document.getElementById('torrent-link').value = `${baseUrl}/api/torrent/${this.videoHash}`;
|
|
|
|
// Magnet link would need to be fetched from the server
|
|
this.fetchMagnetLink();
|
|
}
|
|
|
|
async fetchMagnetLink() {
|
|
try {
|
|
const response = await fetch(`/api/info/${this.videoHash}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.magnet_link) {
|
|
document.getElementById('magnet-link').value = data.magnet_link;
|
|
}
|
|
console.log('Magnet link data:', data);
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not fetch magnet link:', error);
|
|
}
|
|
}
|
|
|
|
handleFatalError(data) {
|
|
let errorMessage = 'Fatal playback error';
|
|
|
|
switch (data.type) {
|
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
errorMessage = 'Network error - check your connection';
|
|
break;
|
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
errorMessage = 'Media error - video format may be unsupported';
|
|
// Try to recover from media errors
|
|
this.hls.recoverMediaError();
|
|
return;
|
|
case Hls.ErrorTypes.OTHER_ERROR:
|
|
errorMessage = 'Playback error - ' + data.details;
|
|
break;
|
|
}
|
|
|
|
this.showError(errorMessage);
|
|
}
|
|
|
|
tryDirectStreaming() {
|
|
console.log('Attempting direct streaming fallback');
|
|
|
|
// Clean up HLS
|
|
if (this.hls) {
|
|
this.hls.destroy();
|
|
this.hls = null;
|
|
}
|
|
|
|
// Try direct video streaming
|
|
const directUrl = `/api/stream/${this.videoHash}`;
|
|
this.video.src = directUrl;
|
|
|
|
this.video.addEventListener('canplay', () => {
|
|
console.log('Direct streaming successful');
|
|
this.updateVideoInfo();
|
|
});
|
|
|
|
this.video.addEventListener('error', (e) => {
|
|
console.error('Direct streaming also failed:', e);
|
|
this.showError('Video playback failed. The file may be corrupted or in an unsupported format.');
|
|
});
|
|
|
|
// Try to play
|
|
this.video.load();
|
|
}
|
|
|
|
showError(message, showExternalPlayerOption = false) {
|
|
const videoContainer = document.querySelector('.video-container');
|
|
|
|
let externalPlayerButtons = '';
|
|
if (showExternalPlayerOption && this.videoHash) {
|
|
externalPlayerButtons = `
|
|
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border-color);">
|
|
<h4 style="margin-bottom: 15px; color: var(--text-primary);">Use External Player:</h4>
|
|
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
|
|
<button onclick="downloadVideo()" class="action-btn"
|
|
style="background-color: var(--primary); color: white;">
|
|
📥 Download File
|
|
</button>
|
|
<button onclick="copyVLCURL()" class="action-btn"
|
|
style="background-color: var(--success); color: white;">
|
|
🎬 Copy VLC URL
|
|
</button>
|
|
<button onclick="openWebSeed()" class="action-btn"
|
|
style="background-color: var(--info); color: white;">
|
|
🌐 Open in VLC
|
|
</button>
|
|
</div>
|
|
<p style="margin-top: 15px; font-size: 0.9rem; color: var(--text-secondary);">
|
|
For best experience with MKV files, use VLC Media Player or similar external video players.
|
|
</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
videoContainer.innerHTML = `
|
|
<div style="display: flex; align-items: center; justify-content: center;
|
|
background-color: var(--bg-secondary); color: var(--danger);
|
|
min-height: 300px; border-radius: 12px;">
|
|
<div style="text-align: center; max-width: 500px; padding: 20px;">
|
|
<div style="font-size: 3rem; margin-bottom: 20px;">${showExternalPlayerOption ? '🎬' : '⚠️'}</div>
|
|
<h3>${showExternalPlayerOption ? 'Browser Compatibility Issue' : 'Playback Error'}</h3>
|
|
<p style="margin-bottom: 20px;">${message}</p>
|
|
<button onclick="location.reload()" class="action-btn"
|
|
style="margin-right: 10px;">🔄 Retry</button>
|
|
${externalPlayerButtons}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Utility functions
|
|
formatTime(seconds) {
|
|
if (!isFinite(seconds)) return '--:--';
|
|
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
showToast(message, type = 'info') {
|
|
const toastContainer = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
|
|
toastContainer.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
// Global functions
|
|
function copyShareLink() {
|
|
const directLink = document.getElementById('direct-link').value;
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(directLink).then(() => {
|
|
player.showToast('Share link copied to clipboard!', 'success');
|
|
});
|
|
} else {
|
|
// Fallback
|
|
const input = document.getElementById('direct-link');
|
|
input.select();
|
|
document.execCommand('copy');
|
|
player.showToast('Share link copied to clipboard!', 'success');
|
|
}
|
|
}
|
|
|
|
function downloadVideo() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const videoHash = urlParams.get('hash');
|
|
const videoName = urlParams.get('name') || 'video';
|
|
|
|
if (videoHash) {
|
|
const url = `/api/download/${videoHash}`;
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = videoName;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
}
|
|
|
|
function getTorrent() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const videoHash = urlParams.get('hash');
|
|
const videoName = urlParams.get('name') || 'video';
|
|
|
|
if (videoHash) {
|
|
const url = `/api/torrent/${videoHash}`;
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${videoName}.torrent`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}
|
|
}
|
|
|
|
function openWebSeed() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const videoHash = urlParams.get('hash');
|
|
|
|
if (videoHash) {
|
|
const url = `/api/webseed/${videoHash}/`;
|
|
window.open(url, '_blank');
|
|
}
|
|
}
|
|
|
|
function copyVLCURL() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const videoHash = urlParams.get('hash');
|
|
|
|
if (videoHash) {
|
|
const streamURL = `${window.location.origin}/api/stream/${videoHash}`;
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(streamURL).then(() => {
|
|
showToastMessage('VLC streaming URL copied to clipboard!', 'success');
|
|
});
|
|
} else {
|
|
// Fallback
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = streamURL;
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
showToastMessage('VLC streaming URL copied to clipboard!', 'success');
|
|
}
|
|
}
|
|
}
|
|
|
|
function showToastMessage(message, type = 'info') {
|
|
const toastContainer = document.getElementById('toast-container');
|
|
if (toastContainer) {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
|
|
toastContainer.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
} else {
|
|
// Fallback to alert if toast container doesn't exist
|
|
alert(message);
|
|
}
|
|
}
|
|
|
|
function copyToClipboard(elementId) {
|
|
const element = document.getElementById(elementId);
|
|
element.select();
|
|
document.execCommand('copy');
|
|
showToastMessage('Copied to clipboard!', 'success');
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme');
|
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
|
|
document.documentElement.setAttribute('data-theme', newTheme);
|
|
localStorage.setItem('theme', newTheme);
|
|
}
|
|
|
|
// Initialize player when page loads
|
|
let player;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
player = new VideoPlayer();
|
|
}); |