Live HLS streams za vseh 6 kanalov (hls.js za deep live-edge), promo fallback v zanki, logo overlay pri nalaganju, Folx barva #C43651, channel hover animacije, random-btn barva

This commit is contained in:
OpenClaw Agent 2026-07-01 09:25:46 +00:00
parent f900c90d88
commit 7fdb2a569d
3 changed files with 219 additions and 50 deletions

View File

@ -6,12 +6,12 @@ const JW_HLS = id => `https://cdn.jwplayer.com/manifests/${id}.m3u8`;
const JW_POSTER = id => `https://cdn.jwplayer.com/v2/media/${id}/poster.jpg?width=1280`; const JW_POSTER = id => `https://cdn.jwplayer.com/v2/media/${id}/poster.jpg?width=1280`;
const CHANNELS = [ const CHANNELS = [
{ key: 'one', name: 'One Music TV', color: '#a84d7e', logo: '/logos/one_mt_neg_b.png', alt: 'logo of One', jw: 'CBM2fSnV' }, { key: 'one', name: 'One Music TV', color: '#a84d7e', logo: '/logos/one_mt_neg_b.png', alt: 'logo of One', jw: 'CBM2fSnV', live: 'https://oneapp.b-cdn.net/master.m3u8' },
{ key: 'zwei', name: 'Zwei Music TV', color: '#f36700', logo: '/logos/zwei_neg_b.png', alt: 'logo of Zwei', jw: 'LZVUn3Qy' }, { key: 'zwei', name: 'Zwei Music TV', color: '#f36700', logo: '/logos/zwei_neg_b.png', alt: 'logo of Zwei', jw: 'LZVUn3Qy', live: 'https://zweiapp.b-cdn.net/master.m3u8' },
{ key: 'mt', name: 'Folx Music TV', color: '#dc1d1d', logo: '/logos/mt_neg_b.png', alt: 'logo of Folx Music',jw: 'UYU6dx3Q' }, { key: 'mt', name: 'Folx Music TV', color: '#C43651', logo: '/logos/mt_neg_b.png', alt: 'logo of Folx Music',jw: 'UYU6dx3Q', live: 'https://folxapp.b-cdn.net/master.m3u8' },
{ key: 'adria', name: 'Adria Music TV', color: '#30a6d1', logo: '/logos/adria_neg_b.png', alt: 'logo of Adria', jw: 'lI4SC7Do' }, { key: 'adria', name: 'Adria Music TV', color: '#30a6d1', logo: '/logos/adria_neg_b.png', alt: 'logo of Adria', jw: 'lI4SC7Do', live: 'https://adriaapp.b-cdn.net/master.m3u8' },
{ key: 'folx-slo', name: 'Folx Slovenija', color: '#a8b539', logo: '/logos/folx_slo_neg_b.png', alt: 'logo of Folx Slovenija', jw: 'aoCWwwSg' }, { key: 'folx-slo', name: 'Folx Slovenija', color: '#a8b539', logo: '/logos/folx_slo_neg_b.png', alt: 'logo of Folx Slovenija', jw: 'aoCWwwSg', live: 'https://folxsloapp.b-cdn.net/master.m3u8' },
{ key: 'one-adria', name: 'One Adria Music TV', color: '#a84d7e', logo: '/logos/one_neg_b.png', alt: 'logo of One Adria', jw: 'IGOSgmvW' }, { key: 'one-adria', name: 'One Adria Music TV', color: '#a84d7e', logo: '/logos/one_neg_b.png', alt: 'logo of One Adria', jw: 'IGOSgmvW', live: 'https://oneadriaapp.b-cdn.net/master.m3u8' },
]; ];
const ABOUT = [ const ABOUT = [
@ -100,6 +100,9 @@ function render(path) {
// ===== HOME ===== // ===== HOME =====
let shakaInstance = null; let shakaInstance = null;
let currentChannel = null; let currentChannel = null;
let playToken = 0; // invalidates async work when channel changes
let liveRetryTimer = null; // background timer that re-checks the live stream
let promoFallbackActive = false;
function renderHome() { function renderHome() {
const main = document.getElementById('main'); const main = document.getElementById('main');
@ -157,18 +160,78 @@ function showSelectChannelBox() {
} }
function showVideoPlayer(ch) { function showVideoPlayer(ch) {
const poster = ch ? ` poster="${JW_POSTER(ch.jw)}"` : ''; const bg = ch ? ch.color : '#000';
const logo = ch ? ch.logo : '';
document.getElementById('player-area').innerHTML = ` document.getElementById('player-area').innerHTML = `
<div class="split__video_ratio"> <div class="split__video_ratio" style="position:relative;">
<video id="video" controls autoplay loop playsinline${poster}></video> <video id="video" controls autoplay loop playsinline></video>
<div id="video-loading" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:${bg};transition:opacity .4s ease;z-index:2;">
<img src="${logo}" alt="" style="max-width:42%;max-height:42%;object-fit:contain;">
</div>
</div> </div>
`; `;
} }
// Hide the channel-logo loading overlay once real playback starts.
function hideLoadingOverlay() {
const el = document.getElementById('video-loading');
if (el) { el.style.opacity = '0'; setTimeout(() => { if (el) el.style.display = 'none'; }, 400); }
}
// Show it again (e.g. when switching channel / falling back).
function showLoadingOverlay(ch) {
const el = document.getElementById('video-loading');
if (!el) return;
if (ch) { el.style.background = ch.color; const img = el.querySelector('img'); if (img) img.src = ch.logo; }
el.style.display = 'flex'; el.style.opacity = '1';
}
// Load an HLS url into the <video> element (Shaka where supported, native HLS
// on Safari). Returns the created Shaka player (or null for native).
async function loadHls(video, url, isLive) {
if (shakaInstance) {
try { await shakaInstance.destroy(); } catch (_) {}
shakaInstance = null;
}
if (window.shaka && shaka.Player.isBrowserSupported()) {
shakaInstance = new shaka.Player(video);
if (isLive) {
// These channels sit ~90-110s behind the live edge (deep HLS buffer).
// Give Shaka enough buffer headroom so playback begins instead of
// stalling on the spinner. Keep only fields valid in Shaka 4.7.
try {
shakaInstance.configure('streaming.bufferingGoal', 30);
shakaInstance.configure('streaming.rebufferingGoal', 4);
shakaInstance.configure('streaming.bufferBehind', 60);
shakaInstance.configure('streaming.lowLatencyMode', false);
} catch (e) { console.warn('shaka configure skipped:', e?.message); }
}
await shakaInstance.load(url);
return shakaInstance;
}
video.src = url;
return null;
}
function clearLiveRetry() {
if (liveRetryTimer) { clearInterval(liveRetryTimer); liveRetryTimer = null; }
}
function setNowPlaying(ch, mode) {
const now = document.getElementById('currently-playing');
if (!now) return;
now.style.display = '';
const label = mode === 'live' ? 'LIVE' : 'promo';
now.innerHTML = `Now playing: <b>${escapeHtml(ch.name)}</b> — ${label}`;
}
async function playChannel(key) { async function playChannel(key) {
const ch = CHANNELS.find(c => c.key === key); const ch = CHANNELS.find(c => c.key === key);
if (!ch) return; if (!ch) return;
currentChannel = ch; currentChannel = ch;
const token = ++playToken; // anything older than this is stale
clearLiveRetry();
destroyHls();
promoFallbackActive = false;
// mark active in sidebar // mark active in sidebar
document.querySelectorAll('.channelList__channelIcon').forEach(el => { document.querySelectorAll('.channelList__channelIcon').forEach(el => {
@ -178,51 +241,149 @@ async function playChannel(key) {
if (link) link.classList.add('channelList__channelIconActive'); if (link) link.classList.add('channelList__channelIconActive');
showVideoPlayer(ch); showVideoPlayer(ch);
// Try Shaka first (HLS); fall back to native <video src=mp4-fallback>
await waitForShaka(); await waitForShaka();
const video = document.getElementById('video'); if (token !== playToken) return; // user switched channel meanwhile
const hlsUrl = JW_HLS(ch.jw);
try { // Hide the logo overlay as soon as real playback begins (any source).
if (shakaInstance) { const vid = document.getElementById('video');
try { await shakaInstance.destroy(); } catch (_) {} if (vid) vid.addEventListener('playing', hideLoadingOverlay, { once: true });
shakaInstance = null;
} if (ch.live) {
if (window.shaka && shaka.Player.isBrowserSupported()) { startLive(ch, token);
shakaInstance = new shaka.Player(video);
await shakaInstance.load(hlsUrl);
} else { } else {
// Safari / native HLS startPromo(ch, token); // promo-only channels (Folx Slo, One Adria)
video.src = hlsUrl;
} }
// When this promo ends, jump to the next channel (cycle through all 6).
// Use `once: true` so the handler fires only for this promo — when we
// start the next channel, a fresh `ended` listener is attached again.
video.loop = false;
video.addEventListener('ended', () => {
const i = CHANNELS.findIndex(c => c.key === currentChannel?.key);
const next = CHANNELS[(i + 1) % CHANNELS.length];
playChannel(next.key);
}, { once: true });
video.play().catch(() => { /* autoplay may be blocked, user can press play */ });
} catch (e) {
console.warn('HLS load failed, falling back to native src:', e?.message);
video.src = hlsUrl;
video.loop = false;
video.addEventListener('ended', () => {
const i = CHANNELS.findIndex(c => c.key === currentChannel?.key);
const next = CHANNELS[(i + 1) % CHANNELS.length];
playChannel(next.key);
}, { once: true });
video.play().catch(() => {});
} }
// Show now playing // ---- LIVE: play the channel's live HLS via hls.js (handles the deep ~90s
const now = document.getElementById('currently-playing'); // live edge these channels sit at, which Shaka stalls on). On any
if (now) { // failure/stall, drop to the looping promo and retry live in background.
now.style.display = ''; let hlsInstance = null;
now.innerHTML = `Now playing: <b>${escapeHtml(ch.name)}</b> — promo`; function destroyHls() {
if (hlsInstance) { try { hlsInstance.destroy(); } catch (_) {} hlsInstance = null; }
}
async function startLive(ch, token) {
const video = document.getElementById('video');
if (!video || token !== playToken) return;
const onProblem = () => {
if (token !== playToken) return;
if (!promoFallbackActive) fallbackToPromo(ch, token);
};
// Watchdog: real playback must start within 15s, else fall to promo.
let started = false;
const watchdog = setTimeout(() => {
if (token === playToken && !started) onProblem();
}, 15000);
video.addEventListener('playing', () => {
started = true; clearTimeout(watchdog);
}, { once: true });
// tear down any previous players
if (shakaInstance) { try { await shakaInstance.destroy(); } catch (_) {} shakaInstance = null; }
destroyHls();
video.loop = true;
video.onerror = onProblem;
if (window.Hls && Hls.isSupported()) {
hlsInstance = new Hls({
liveSyncDuration: 30,
liveMaxLatencyDuration: 120,
maxBufferLength: 30,
manifestLoadingTimeOut: 20000,
fragLoadingTimeOut: 20000
});
hlsInstance.on(Hls.Events.ERROR, (e, d) => {
if (d && d.fatal) { console.warn('hls fatal', d.type, d.details); onProblem(); }
});
hlsInstance.loadSource(ch.live);
hlsInstance.attachMedia(video);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
if (token === playToken) video.play().catch(() => {});
});
setNowPlaying(ch, 'live');
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari native HLS
video.src = ch.live;
video.play().catch(() => {});
setNowPlaying(ch, 'live');
} else {
clearTimeout(watchdog);
onProblem();
}
}
// ---- PROMO fallback (looping) + background live retry every 15s.
async function fallbackToPromo(ch, token) {
if (token !== playToken) return;
promoFallbackActive = true;
destroyHls();
const video = document.getElementById('video');
if (!video) return;
showLoadingOverlay(ch);
video.addEventListener('playing', hideLoadingOverlay, { once: true });
video.onerror = null; video.onstalled = null;
try {
await loadHls(video, JW_HLS(ch.jw), false);
if (token !== playToken) return;
video.loop = true; // promo loops until live returns
video.play().catch(() => {});
setNowPlaying(ch, 'promo');
} catch (_) {}
clearLiveRetry();
liveRetryTimer = setInterval(() => {
if (token !== playToken) { clearLiveRetry(); return; }
probeLive(ch.live).then(ok => {
if (ok && token === playToken) { // live verified playable, switch back
clearLiveRetry();
promoFallbackActive = false;
startLive(ch, token);
}
});
}, 15000);
}
// Probe whether the live stream actually plays (not just HTTP 200). Spins up a
// detached hls.js on a throwaway <video>, waits for a real timeupdate, then
// tears it down. Resolves true only if playback genuinely advanced.
function probeLive(url) {
return new Promise(resolve => {
if (!(window.Hls && Hls.isSupported())) { resolve(false); return; }
const v = document.createElement('video');
v.muted = true; v.playsInline = true;
const h = new Hls({ liveSyncDuration: 30, manifestLoadingTimeOut: 12000, fragLoadingTimeOut: 12000 });
let done = false;
const finish = (ok) => {
if (done) return; done = true;
try { h.destroy(); } catch (_) {}
v.remove();
resolve(ok);
};
h.on(Hls.Events.ERROR, (e, d) => { if (d && d.fatal) finish(false); });
v.addEventListener('timeupdate', () => { if (v.currentTime > 0.1) finish(true); });
h.loadSource(url);
h.attachMedia(v);
h.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}));
setTimeout(() => finish(false), 12000); // give it 12s to actually start
});
}
// ---- PROMO-only channels: loop the promo forever (no live source).
async function startPromo(ch, token) {
const video = document.getElementById('video');
if (!video || token !== playToken) return;
destroyHls();
try {
await loadHls(video, JW_HLS(ch.jw), false);
if (token !== playToken) return;
video.loop = true;
video.play().catch(() => {});
setNowPlaying(ch, 'promo');
} catch (e) {
console.warn('promo load failed:', e?.message);
} }
} }

View File

@ -27,6 +27,7 @@
<!-- Shaka Player for HLS playback --> <!-- Shaka Player for HLS playback -->
<script defer src="https://cdn.jsdelivr.net/npm/shaka-player@4.7.11/dist/shaka-player.compiled.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/shaka-player@4.7.11/dist/shaka-player.compiled.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js"></script>
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/styles.css">
</head> </head>

View File

@ -301,3 +301,10 @@ a:hover { border-bottom: 1px solid #313131; }
color: #888; color: #888;
padding: 30px 50px; padding: 30px 50px;
} }
/* channel-smooth-scale */
.channelList__channel { transition: transform .25s cubic-bezier(.34,1.56,.64,1); transform-origin: left center; will-change: transform; }
.channelList__channel:hover { transform: scale(1.12); }
.channelList__channel:active { transform: scale(1.04); transition: transform .1s ease; }
.channelList__channelIcon, .channelList__channelIcon svg, .channelList__channelIcon svg path { transition: all .25s ease; }
#random-btn { color: #D91D4A; }