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:
parent
f900c90d88
commit
7fdb2a569d
261
public/app.js
261
public/app.js
@ -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);
|
} else {
|
||||||
await shakaInstance.load(hlsUrl);
|
startPromo(ch, token); // promo-only channels (Folx Slo, One Adria)
|
||||||
} else {
|
|
||||||
// Safari / native HLS
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user