Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>IPTV Player - Hugging Face</title> | |
<style> | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
min-height: 100vh; | |
color: white; | |
overflow-x: hidden; | |
} | |
.container { | |
max-width: 1200px; | |
margin: 0 auto; | |
padding: 20px; | |
} | |
.header { | |
text-align: center; | |
margin-bottom: 30px; | |
animation: fadeInDown 0.8s ease-out; | |
} | |
.header h1 { | |
font-size: 2.5rem; | |
margin-bottom: 10px; | |
text-shadow: 2px 2px 4px rgba(0,0,0,0.5); | |
background: linear-gradient(45deg, #fff, #e0e0e0); | |
-webkit-background-clip: text; | |
-webkit-text-fill-color: transparent; | |
background-clip: text; | |
} | |
.header p { | |
font-size: 1.1rem; | |
opacity: 0.9; | |
} | |
.player-container { | |
display: grid; | |
grid-template-columns: 1fr 320px; | |
gap: 20px; | |
margin-bottom: 30px; | |
} | |
.video-section { | |
background: rgba(255,255,255,0.1); | |
border-radius: 20px; | |
padding: 25px; | |
backdrop-filter: blur(15px); | |
border: 1px solid rgba(255,255,255,0.2); | |
box-shadow: 0 8px 32px rgba(0,0,0,0.1); | |
animation: fadeInLeft 0.8s ease-out; | |
} | |
.video-player { | |
width: 100%; | |
height: 400px; | |
border-radius: 15px; | |
background: #000; | |
margin-bottom: 20px; | |
box-shadow: 0 4px 20px rgba(0,0,0,0.3); | |
} | |
.channel-info { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 20px; | |
flex-wrap: wrap; | |
gap: 10px; | |
} | |
.current-channel { | |
font-size: 1.3rem; | |
font-weight: bold; | |
text-shadow: 1px 1px 2px rgba(0,0,0,0.3); | |
} | |
.controls { | |
display: flex; | |
gap: 10px; | |
align-items: center; | |
flex-wrap: wrap; | |
} | |
.btn { | |
padding: 10px 18px; | |
border: none; | |
border-radius: 8px; | |
background: rgba(255,255,255,0.2); | |
color: white; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
font-size: 14px; | |
font-weight: 500; | |
backdrop-filter: blur(10px); | |
border: 1px solid rgba(255,255,255,0.1); | |
} | |
.btn:hover { | |
background: rgba(255,255,255,0.3); | |
transform: translateY(-2px); | |
box-shadow: 0 4px 15px rgba(0,0,0,0.2); | |
} | |
.btn:active { | |
transform: translateY(0); | |
} | |
.volume-control { | |
display: flex; | |
align-items: center; | |
gap: 15px; | |
margin-top: 10px; | |
} | |
.volume-slider { | |
width: 120px; | |
height: 6px; | |
border-radius: 3px; | |
background: rgba(255,255,255,0.3); | |
outline: none; | |
-webkit-appearance: none; | |
} | |
.volume-slider::-webkit-slider-thumb { | |
appearance: none; | |
width: 18px; | |
height: 18px; | |
border-radius: 50%; | |
background: white; | |
cursor: pointer; | |
box-shadow: 0 2px 6px rgba(0,0,0,0.3); | |
} | |
.playlist-section { | |
background: rgba(255,255,255,0.1); | |
border-radius: 20px; | |
padding: 25px; | |
backdrop-filter: blur(15px); | |
border: 1px solid rgba(255,255,255,0.2); | |
max-height: 600px; | |
overflow-y: auto; | |
box-shadow: 0 8px 32px rgba(0,0,0,0.1); | |
animation: fadeInRight 0.8s ease-out; | |
} | |
.playlist-header { | |
font-size: 1.4rem; | |
margin-bottom: 20px; | |
text-align: center; | |
font-weight: bold; | |
text-shadow: 1px 1px 2px rgba(0,0,0,0.3); | |
} | |
.url-input-section { | |
margin-bottom: 25px; | |
} | |
.url-input { | |
width: 100%; | |
padding: 12px 15px; | |
border: none; | |
border-radius: 8px; | |
background: rgba(255,255,255,0.2); | |
color: white; | |
margin-bottom: 12px; | |
backdrop-filter: blur(10px); | |
border: 1px solid rgba(255,255,255,0.1); | |
transition: all 0.3s ease; | |
} | |
.url-input:focus { | |
outline: none; | |
background: rgba(255,255,255,0.25); | |
box-shadow: 0 0 15px rgba(255,255,255,0.2); | |
} | |
.url-input::placeholder { | |
color: rgba(255,255,255,0.7); | |
} | |
.button-group { | |
display: flex; | |
gap: 10px; | |
flex-wrap: wrap; | |
} | |
.channel-list { | |
list-style: none; | |
} | |
.channel-item { | |
padding: 15px; | |
margin-bottom: 8px; | |
border-radius: 10px; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
background: rgba(255,255,255,0.1); | |
border-left: 4px solid transparent; | |
backdrop-filter: blur(10px); | |
border: 1px solid rgba(255,255,255,0.1); | |
} | |
.channel-item:hover { | |
background: rgba(255,255,255,0.2); | |
transform: translateX(8px); | |
box-shadow: 0 4px 15px rgba(0,0,0,0.1); | |
} | |
.channel-item.active { | |
background: rgba(255,255,255,0.3); | |
border-left-color: #fff; | |
box-shadow: 0 6px 20px rgba(0,0,0,0.2); | |
} | |
.channel-name { | |
font-weight: bold; | |
margin-bottom: 4px; | |
font-size: 1rem; | |
} | |
.channel-url { | |
font-size: 0.85rem; | |
opacity: 0.8; | |
word-break: break-all; | |
line-height: 1.3; | |
} | |
.error-message { | |
background: rgba(255,59,48,0.3); | |
border: 1px solid rgba(255,59,48,0.5); | |
padding: 15px; | |
border-radius: 10px; | |
margin: 15px 0; | |
display: none; | |
backdrop-filter: blur(10px); | |
animation: shake 0.5s ease-in-out; | |
} | |
.success-message { | |
background: rgba(52,199,89,0.3); | |
border: 1px solid rgba(52,199,89,0.5); | |
padding: 15px; | |
border-radius: 10px; | |
margin: 15px 0; | |
display: none; | |
backdrop-filter: blur(10px); | |
} | |
.loading { | |
text-align: center; | |
padding: 20px; | |
font-style: italic; | |
opacity: 0.8; | |
} | |
.loading::after { | |
content: ''; | |
display: inline-block; | |
width: 20px; | |
height: 20px; | |
border: 2px solid rgba(255,255,255,0.3); | |
border-radius: 50%; | |
border-top-color: white; | |
animation: spin 1s ease-in-out infinite; | |
margin-left: 10px; | |
} | |
.stats { | |
display: flex; | |
justify-content: space-around; | |
margin-top: 20px; | |
padding: 15px; | |
background: rgba(255,255,255,0.1); | |
border-radius: 10px; | |
backdrop-filter: blur(10px); | |
} | |
.stat-item { | |
text-align: center; | |
} | |
.stat-number { | |
font-size: 1.5rem; | |
font-weight: bold; | |
color: #fff; | |
} | |
.stat-label { | |
font-size: 0.9rem; | |
opacity: 0.8; | |
} | |
@media (max-width: 768px) { | |
.player-container { | |
grid-template-columns: 1fr; | |
} | |
.header h1 { | |
font-size: 2rem; | |
} | |
.video-player { | |
height: 250px; | |
} | |
.channel-info { | |
flex-direction: column; | |
align-items: flex-start; | |
} | |
.controls { | |
width: 100%; | |
justify-content: center; | |
} | |
.button-group { | |
justify-content: center; | |
} | |
} | |
/* Custom scrollbar */ | |
.playlist-section::-webkit-scrollbar { | |
width: 8px; | |
} | |
.playlist-section::-webkit-scrollbar-track { | |
background: rgba(255,255,255,0.1); | |
border-radius: 4px; | |
} | |
.playlist-section::-webkit-scrollbar-thumb { | |
background: rgba(255,255,255,0.3); | |
border-radius: 4px; | |
} | |
.playlist-section::-webkit-scrollbar-thumb:hover { | |
background: rgba(255,255,255,0.5); | |
} | |
/* Animations */ | |
@keyframes fadeInDown { | |
from { | |
opacity: 0; | |
transform: translateY(-30px); | |
} | |
to { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
} | |
@keyframes fadeInLeft { | |
from { | |
opacity: 0; | |
transform: translateX(-30px); | |
} | |
to { | |
opacity: 1; | |
transform: translateX(0); | |
} | |
} | |
@keyframes fadeInRight { | |
from { | |
opacity: 0; | |
transform: translateX(30px); | |
} | |
to { | |
opacity: 1; | |
transform: translateX(0); | |
} | |
} | |
@keyframes spin { | |
to { transform: rotate(360deg); } | |
} | |
@keyframes shake { | |
0%, 100% { transform: translateX(0); } | |
25% { transform: translateX(-5px); } | |
75% { transform: translateX(5px); } | |
} | |
.hf-badge { | |
position: fixed; | |
bottom: 20px; | |
right: 20px; | |
background: rgba(255,255,255,0.2); | |
padding: 8px 12px; | |
border-radius: 20px; | |
font-size: 0.8rem; | |
backdrop-filter: blur(10px); | |
border: 1px solid rgba(255,255,255,0.1); | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="header"> | |
<h1>📺 IPTV Player</h1> | |
<p>Stream your favorite channels with modern web technology</p> | |
</div> | |
<div class="player-container"> | |
<div class="video-section"> | |
<video class="video-player" controls id="videoPlayer" preload="metadata"> | |
<source src="" type="application/x-mpegURL"> | |
<source src="" type="video/mp4"> | |
Your browser does not support the video tag. | |
</video> | |
<div class="channel-info"> | |
<div class="current-channel" id="currentChannel">Select a channel to start</div> | |
<div class="controls"> | |
<button class="btn" id="playBtn">▶️ Play</button> | |
<button class="btn" id="pauseBtn">⏸️ Pause</button> | |
<button class="btn" id="fullscreenBtn">🔳 Fullscreen</button> | |
</div> | |
</div> | |
<div class="volume-control"> | |
<span>🔊</span> | |
<input type="range" class="volume-slider" id="volumeSlider" min="0" max="100" value="50"> | |
<span id="volumeValue">50%</span> | |
</div> | |
<div class="error-message" id="errorMessage"></div> | |
<div class="success-message" id="successMessage"></div> | |
<div class="stats"> | |
<div class="stat-item"> | |
<div class="stat-number" id="channelCount">0</div> | |
<div class="stat-label">Channels</div> | |
</div> | |
<div class="stat-item"> | |
<div class="stat-number" id="currentTime">00:00</div> | |
<div class="stat-label">Duration</div> | |
</div> | |
</div> | |
</div> | |
<div class="playlist-section"> | |
<div class="playlist-header">📺 Channel Playlist</div> | |
<div class="url-input-section"> | |
<input type="text" class="url-input" id="streamUrl" placeholder="Enter stream URL (m3u8, mp4, etc.)"> | |
<input type="text" class="url-input" id="channelName" placeholder="Channel name (optional)"> | |
<div class="button-group"> | |
<button class="btn" id="addChannelBtn">➕ Add</button> | |
<button class="btn" id="loadM3uFileBtn">📂 Load File</button> | |
<button class="btn" id="loadM3uUrlBtn">🌐 Load URL</button> | |
<button class="btn" id="clearChannelsBtn">🗑️ Clear All</button> | |
</div> | |
<input type="file" id="m3uFile" accept=".m3u,.m3u8,.txt" style="display: none;"> | |
</div> | |
<div class="loading" id="loadingMessage" style="display: none;">Loading channels</div> | |
<ul class="channel-list" id="channelList"> | |
<!-- Channels will be loaded here --> | |
</ul> | |
</div> | |
</div> | |
</div> | |
<div class="hf-badge"> | |
🤗 Hosted on Hugging Face | |
</div> | |
<script> | |
class IPTVPlayer { | |
constructor() { | |
this.videoPlayer = document.getElementById('videoPlayer'); | |
this.currentChannel = document.getElementById('currentChannel'); | |
this.channelList = document.getElementById('channelList'); | |
this.streamUrl = document.getElementById('streamUrl'); | |
this.channelName = document.getElementById('channelName'); | |
this.errorMessage = document.getElementById('errorMessage'); | |
this.successMessage = document.getElementById('successMessage'); | |
this.loadingMessage = document.getElementById('loadingMessage'); | |
this.channelCount = document.getElementById('channelCount'); | |
this.currentTime = document.getElementById('currentTime'); | |
this.channels = []; | |
this.currentIndex = -1; | |
this.initializeEventListeners(); | |
} | |
async init() { | |
this.channels = this.loadChannelsFromStorage(); | |
if (this.channels.length === 0) { | |
await this.loadChannelsFromUrl('https://iptv-org.github.io/iptv/index.m3u', { replace: true }); | |
} | |
this.renderChannelList(); | |
this.updateStats(); | |
} | |
loadFallbackChannels() { | |
this.channels = [ | |
{ name: "Demo: Tears of Steel", url: "https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8" }, | |
{ name: "Demo: Sintel", url: "https://bitdash-a.akamaihd.net/content/sintel/hls/playlist.m3u8" }, | |
{ name: "Demo: Big Buck Bunny", url: "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8" } | |
]; | |
this.showSuccess('Loaded fallback demo channels.'); | |
} | |
initializeEventListeners() { | |
document.getElementById('playBtn').addEventListener('click', () => this.play()); | |
document.getElementById('pauseBtn').addEventListener('click', () => this.pause()); | |
document.getElementById('fullscreenBtn').addEventListener('click', () => this.toggleFullscreen()); | |
const volumeSlider = document.getElementById('volumeSlider'); | |
volumeSlider.addEventListener('input', (e) => this.setVolume(e.target.value / 100)); | |
document.getElementById('addChannelBtn').addEventListener('click', () => this.addChannel()); | |
document.getElementById('clearChannelsBtn').addEventListener('click', () => this.clearAllChannels()); | |
document.getElementById('loadM3uFileBtn').addEventListener('click', () => document.getElementById('m3uFile').click()); | |
document.getElementById('m3uFile').addEventListener('change', (e) => this.handleM3UFile(e)); | |
document.getElementById('loadM3uUrlBtn').addEventListener('click', () => { | |
const url = prompt('Enter the URL of the M3U playlist:'); | |
if (url && this.isValidUrl(url)) { | |
this.loadChannelsFromUrl(url, { replace: false }); | |
} else if (url) { | |
this.showError('The URL you entered is not valid.'); | |
} | |
}); | |
this.streamUrl.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.addChannel(); }); | |
this.channelName.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.addChannel(); }); | |
this.videoPlayer.addEventListener('error', () => this.handleVideoError()); | |
this.videoPlayer.addEventListener('loadstart', () => this.showLoading()); | |
this.videoPlayer.addEventListener('canplay', () => this.hideLoading()); | |
this.videoPlayer.addEventListener('timeupdate', () => this.updateCurrentTime()); | |
document.addEventListener('keydown', (e) => this.handleKeyboardShortcuts(e)); | |
} | |
handleKeyboardShortcuts(e) { | |
if (e.target.tagName === 'INPUT') return; | |
e.preventDefault(); | |
switch(e.key) { | |
case ' ': this.videoPlayer.paused ? this.play() : this.pause(); break; | |
case 'f': this.toggleFullscreen(); break; | |
case 'ArrowUp': this.adjustVolume(0.1); break; | |
case 'ArrowDown': this.adjustVolume(-0.1); break; | |
} | |
} | |
setVolume(level) { | |
this.videoPlayer.volume = level; | |
document.getElementById('volumeSlider').value = level * 100; | |
document.getElementById('volumeValue').textContent = Math.round(level * 100) + '%'; | |
} | |
adjustVolume(delta) { | |
this.setVolume(Math.max(0, Math.min(1, this.videoPlayer.volume + delta))); | |
} | |
updateCurrentTime() { | |
if (this.videoPlayer.duration && !isNaN(this.videoPlayer.duration) && isFinite(this.videoPlayer.duration)) { | |
const formatTime = (time) => { | |
const minutes = Math.floor(time / 60); | |
const seconds = Math.floor(time % 60); | |
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; | |
}; | |
this.currentTime.textContent = formatTime(this.videoPlayer.currentTime); | |
} else { | |
this.currentTime.textContent = 'Live'; | |
} | |
} | |
play() { this.videoPlayer.play().catch(e => this.showError(`Playback error: ${e.message}`)); } | |
pause() { this.videoPlayer.pause(); } | |
toggleFullscreen() { | |
if (document.fullscreenElement) { | |
document.exitFullscreen(); | |
} else { | |
this.videoPlayer.requestFullscreen().catch(e => this.showError(`Fullscreen error: ${e.message}`)); | |
} | |
} | |
addChannel() { | |
const url = this.streamUrl.value.trim(); | |
const name = this.channelName.value.trim() || `Channel ${this.channels.length + 1}`; | |
if (!url || !this.isValidUrl(url)) { | |
this.showError('Please enter a valid stream URL'); | |
return; | |
} | |
if (this.channels.some(channel => channel.url === url)) { | |
this.showError('This channel already exists in the playlist'); | |
return; | |
} | |
this.channels.push({ name, url }); | |
this.saveChannelsToStorage(); | |
this.renderChannelList(); | |
this.updateStats(); | |
this.streamUrl.value = ''; | |
this.channelName.value = ''; | |
this.showSuccess(`Channel "${name}" added successfully!`); | |
} | |
clearAllChannels() { | |
if (confirm('Are you sure you want to clear all channels? This will load the default playlist on next refresh.')) { | |
this.channels = []; | |
this.saveChannelsToStorage(); | |
this.renderChannelList(); | |
this.updateStats(); | |
this.currentChannel.textContent = 'Select a channel to start'; | |
this.videoPlayer.src = ''; | |
this.showSuccess('All channels cleared.'); | |
} | |
} | |
async loadChannelsFromUrl(url, options = { replace: false }) { | |
this.showLoading(`Loading from ${new URL(url).hostname}...`); | |
try { | |
const response = await fetch(url); | |
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | |
const m3uContent = await response.text(); | |
const newChannels = this.parseM3U(m3uContent); | |
if (newChannels.length > 0) { | |
if (options.replace) { | |
this.channels = newChannels; | |
} else { | |
const uniqueNewChannels = newChannels.filter(nc => !this.channels.some(ec => ec.url === nc.url)); | |
this.channels.push(...uniqueNewChannels); | |
} | |
this.saveChannelsToStorage(); | |
this.showSuccess(`Successfully loaded ${newChannels.length} channels.`); | |
} else { | |
this.showError('No valid channels found at the URL.'); | |
if (options.replace) this.loadFallbackChannels(); | |
} | |
} catch (error) { | |
this.showError(`Failed to load playlist: ${error.message}`); | |
if (options.replace) this.loadFallbackChannels(); | |
} finally { | |
this.hideLoading(); | |
} | |
} | |
handleM3UFile(event) { | |
const file = event.target.files[0]; | |
if (!file) return; | |
this.showLoading('Parsing M3U file...'); | |
const reader = new FileReader(); | |
reader.onload = (e) => { | |
try { | |
const newChannels = this.parseM3U(e.target.result); | |
if (newChannels.length > 0) { | |
const uniqueNewChannels = newChannels.filter(nc => !this.channels.some(ec => ec.url === nc.url)); | |
this.channels.push(...uniqueNewChannels); | |
this.saveChannelsToStorage(); | |
this.renderChannelList(); | |
this.updateStats(); | |
this.showSuccess(`Added ${uniqueNewChannels.length} new channels from file.`); | |
} else { | |
this.showError('No valid channels found in the file.'); | |
} | |
} catch (error) { | |
this.showError(`File parse error: ${error.message}`); | |
} finally { | |
this.hideLoading(); | |
} | |
}; | |
reader.onerror = () => this.showError('Failed to read the file.'); | |
reader.readAsText(file); | |
event.target.value = ''; // Reset file input | |
} | |
parseM3U(content) { | |
const lines = content.split('\n'); | |
const parsedChannels = []; | |
let currentChannel = {}; | |
lines.forEach((line, index) => { | |
line = line.trim(); | |
if (line.startsWith('#EXTINF:')) { | |
const nameMatch = line.match(/,(.+)$/); | |
const tvgNameMatch = line.match(/tvg-name="([^"]+)"/); | |
currentChannel.name = tvgNameMatch?.[1] || nameMatch?.[1] || `Channel ${index}`; | |
} else if (line && !line.startsWith('#') && this.isValidUrl(line)) { | |
currentChannel.url = line; | |
parsedChannels.push({ ...currentChannel }); | |
currentChannel = {}; | |
} | |
}); | |
return parsedChannels; | |
} | |
renderChannelList() { | |
this.channelList.innerHTML = ''; | |
this.channels.forEach((channel, index) => { | |
const li = document.createElement('li'); | |
li.className = 'channel-item'; | |
li.dataset.index = index; | |
li.title = `${channel.name}\n${channel.url}`; | |
li.innerHTML = ` | |
<div class="channel-name">${this.escapeHtml(channel.name)}</div> | |
<div class="channel-url">${this.escapeHtml(channel.url)}</div> | |
`; | |
li.addEventListener('click', () => this.selectChannel(index)); | |
this.channelList.appendChild(li); | |
}); | |
} | |
selectChannel(index) { | |
if (index < 0 || index >= this.channels.length) return; | |
this.currentIndex = index; | |
const channel = this.channels[index]; | |
document.querySelectorAll('.channel-item.active').forEach(item => item.classList.remove('active')); | |
const selectedItem = document.querySelector(`[data-index="${index}"]`); | |
if (selectedItem) { | |
selectedItem.classList.add('active'); | |
selectedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
} | |
this.currentChannel.textContent = channel.name; | |
this.videoPlayer.src = channel.url; | |
this.videoPlayer.load(); | |
this.play(); | |
this.hideError(); | |
} | |
updateStats() { | |
this.channelCount.textContent = this.channels.length; | |
} | |
handleVideoError() { | |
const error = this.videoPlayer.error; | |
let message = 'An unknown video error occurred.'; | |
if (error) { | |
switch (error.code) { | |
case error.MEDIA_ERR_ABORTED: message = 'Video loading aborted.'; break; | |
case error.MEDIA_ERR_NETWORK: message = 'Network error. Check connection or CORS policy.'; break; | |
case error.MEDIA_ERR_DECODE: message = 'Video decoding error. Format may be unsupported.'; break; | |
case error.MEDIA_ERR_SRC_NOT_SUPPORTED: message = 'Stream format not supported by this browser.'; break; | |
} | |
} | |
this.showError(message); | |
this.hideLoading(); | |
} | |
showError(message) { this.showMessage(this.errorMessage, message, 5000); } | |
showSuccess(message) { this.showMessage(this.successMessage, message, 3000); } | |
showMessage(element, message, duration) { | |
element.textContent = message; | |
element.style.display = 'block'; | |
// Hide other message type | |
(element === this.errorMessage ? this.successMessage : this.errorMessage).style.display = 'none'; | |
setTimeout(() => { element.style.display = 'none'; }, duration); | |
} | |
showLoading(message = 'Loading...') { | |
this.loadingMessage.textContent = message; | |
this.loadingMessage.style.display = 'block'; | |
} | |
hideLoading() { this.loadingMessage.style.display = 'none'; } | |
saveChannelsToStorage() { | |
try { | |
localStorage.setItem('iptv_player_channels', JSON.stringify(this.channels)); | |
} catch (e) { | |
console.error('Failed to save to local storage:', e); | |
} | |
} | |
loadChannelsFromStorage() { | |
try { | |
const stored = localStorage.getItem('iptv_player_channels'); | |
return stored ? JSON.parse(stored) : []; | |
} catch (e) { | |
console.error('Failed to load from local storage:', e); | |
return []; | |
} | |
} | |
isValidUrl(string) { try { new URL(string); return true; } catch (_) { return false; } } | |
escapeHtml(str) { | |
const p = document.createElement('p'); | |
p.textContent = str; | |
return p.innerHTML; | |
} | |
} | |
document.addEventListener('DOMContentLoaded', () => { | |
const player = new IPTVPlayer(); | |
player.init(); | |
}); | |
</script> | |
</body> | |
</html> |