feat: Add multi-CDN integration for global video delivery
- Create CDN manager with multi-provider support (Bunny, Cloudflare, S3, Backblaze, Wasabi) - Implement automatic CDN upload and failover system - Add multi-quality video support (360p-2160p) - Build quality selector UI with auto-detection - Track CDN bandwidth usage for cost monitoring - Implement cache purging API - Add background CDN upload worker script - Create comprehensive CDN documentation Features: - Multi-CDN support with priority-based failover - BunnyCDN, Cloudflare R2, AWS S3, Backblaze B2, Wasabi integration - Multi-quality delivery (adaptive quality selection) - Auto quality based on connection speed - User preference persistence - Bandwidth tracking per provider - Cache purge API - Cost optimization strategies - Production-ready with error handling
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Video Quality Selector
|
||||
* Allows users to switch between quality levels
|
||||
*/
|
||||
|
||||
class QualitySelector {
|
||||
constructor(videoId, options = {}) {
|
||||
this.videoId = videoId;
|
||||
this.playerElement = options.playerElement || document.querySelector('video');
|
||||
this.containerElement = options.containerElement || document.querySelector('.video-controls');
|
||||
this.qualities = [];
|
||||
this.currentQuality = 'auto';
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadQualities();
|
||||
this.render();
|
||||
this.attachEventListeners();
|
||||
}
|
||||
|
||||
async loadQualities() {
|
||||
try {
|
||||
const response = await fetch(`/api/cdn.php?action=qualities&video_id=${this.videoId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.qualities = data.qualities;
|
||||
|
||||
// Add "Auto" option
|
||||
this.qualities.unshift({
|
||||
quality_label: 'auto',
|
||||
resolution: 'Auto',
|
||||
cdn_url: null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load qualities:', error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.qualities.length <= 1) {
|
||||
// No quality options available
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'quality-selector';
|
||||
container.innerHTML = `
|
||||
<button class="quality-button" id="qualityButton">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 3a7 7 0 100 14 7 7 0 000-14zM2 10a8 8 0 1116 0 8 8 0 01-16 0z"/>
|
||||
<path d="M10 7a3 3 0 100 6 3 3 0 000-6z"/>
|
||||
</svg>
|
||||
<span id="qualityLabel">Auto</span>
|
||||
</button>
|
||||
<div class="quality-menu" id="qualityMenu" style="display: none;">
|
||||
${this.renderQualityOptions()}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.containerElement) {
|
||||
this.containerElement.appendChild(container);
|
||||
} else {
|
||||
// Fallback: insert after video player
|
||||
this.playerElement.parentNode.insertBefore(container, this.playerElement.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
renderQualityOptions() {
|
||||
return this.qualities.map(q => `
|
||||
<div class="quality-option ${q.quality_label === this.currentQuality ? 'active' : ''}"
|
||||
data-quality="${q.quality_label}"
|
||||
data-url="${q.cdn_url || ''}">
|
||||
${q.resolution || q.quality_label}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const button = document.getElementById('qualityButton');
|
||||
const menu = document.getElementById('qualityMenu');
|
||||
|
||||
if (!button || !menu) return;
|
||||
|
||||
// Toggle menu
|
||||
button.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isVisible = menu.style.display === 'block';
|
||||
menu.style.display = isVisible ? 'none' : 'block';
|
||||
});
|
||||
|
||||
// Close menu on outside click
|
||||
document.addEventListener('click', () => {
|
||||
menu.style.display = 'none';
|
||||
});
|
||||
|
||||
// Quality selection
|
||||
menu.addEventListener('click', (e) => {
|
||||
const option = e.target.closest('.quality-option');
|
||||
if (option) {
|
||||
const quality = option.dataset.quality;
|
||||
const url = option.dataset.url;
|
||||
|
||||
this.switchQuality(quality, url);
|
||||
menu.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async switchQuality(quality, url) {
|
||||
if (quality === this.currentQuality) return;
|
||||
|
||||
const wasPlaying = !this.playerElement.paused;
|
||||
const currentTime = this.playerElement.currentTime;
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.quality-option').forEach(option => {
|
||||
option.classList.toggle('active', option.dataset.quality === quality);
|
||||
});
|
||||
|
||||
// Update label
|
||||
const label = document.getElementById('qualityLabel');
|
||||
if (label) {
|
||||
const selectedQuality = this.qualities.find(q => q.quality_label === quality);
|
||||
label.textContent = selectedQuality?.resolution || quality;
|
||||
}
|
||||
|
||||
if (quality === 'auto') {
|
||||
// Auto quality - select best based on connection
|
||||
url = await this.selectAutoQuality();
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.error('No URL available for quality:', quality);
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch video source
|
||||
this.playerElement.src = url;
|
||||
this.playerElement.currentTime = currentTime;
|
||||
|
||||
if (wasPlaying) {
|
||||
this.playerElement.play();
|
||||
}
|
||||
|
||||
this.currentQuality = quality;
|
||||
|
||||
// Save preference
|
||||
localStorage.setItem('preferredQuality', quality);
|
||||
}
|
||||
|
||||
async selectAutoQuality() {
|
||||
// Measure connection speed (simplified)
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
|
||||
let selectedQuality = 'original';
|
||||
|
||||
if (connection) {
|
||||
const effectiveType = connection.effectiveType;
|
||||
|
||||
// Map connection type to quality
|
||||
if (effectiveType === 'slow-2g' || effectiveType === '2g') {
|
||||
selectedQuality = '360p';
|
||||
} else if (effectiveType === '3g') {
|
||||
selectedQuality = '480p';
|
||||
} else if (effectiveType === '4g') {
|
||||
selectedQuality = '1080p';
|
||||
} else {
|
||||
selectedQuality = '720p';
|
||||
}
|
||||
}
|
||||
|
||||
// Find best available quality
|
||||
const available = this.qualities.find(q => q.quality_label === selectedQuality);
|
||||
|
||||
if (available && available.cdn_url) {
|
||||
return available.cdn_url;
|
||||
}
|
||||
|
||||
// Fallback to highest available
|
||||
const highestQuality = this.qualities.filter(q => q.cdn_url).pop();
|
||||
return highestQuality?.cdn_url;
|
||||
}
|
||||
|
||||
restorePreference() {
|
||||
const preferred = localStorage.getItem('preferredQuality');
|
||||
|
||||
if (preferred && preferred !== 'auto') {
|
||||
const quality = this.qualities.find(q => q.quality_label === preferred);
|
||||
|
||||
if (quality && quality.cdn_url) {
|
||||
this.switchQuality(preferred, quality.cdn_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const videoElement = document.querySelector('video');
|
||||
const videoId = new URLSearchParams(window.location.search).get('v');
|
||||
|
||||
if (videoElement && videoId) {
|
||||
window.qualitySelector = new QualitySelector(videoId, {
|
||||
playerElement: videoElement,
|
||||
containerElement: document.querySelector('.video-controls')
|
||||
});
|
||||
|
||||
// Restore user preference after qualities load
|
||||
setTimeout(() => {
|
||||
if (window.qualitySelector) {
|
||||
window.qualitySelector.restorePreference();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user