515 lines
21 KiB
JavaScript
515 lines
21 KiB
JavaScript
// FolxPlay clone — vanilla JS SPA
|
|
// Each channel plays its JW Player promo (HLS from cdn.jwplayer.com).
|
|
// No tracking, no AdSense, no external API.
|
|
|
|
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 CHANNELS = [
|
|
{ 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', live: 'https://zweiapp.b-cdn.net/master.m3u8' },
|
|
{ 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', 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', 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', live: 'https://oneadriaapp.b-cdn.net/master.m3u8' },
|
|
];
|
|
|
|
const ABOUT = [
|
|
{ title: 'FOLX MUSIC TELEVISION', text: 'the whole area of folk music is covered with exclusive content. Over 90% of the content is created by our own FOLX NETWORK production.' },
|
|
{ title: 'ONE MUSIC TELEVISION', text: 'covers the area of the new German-speaking and world-famous pop and rock sound production and is additionally enriched with old German and world-famous classics of the 80s, 90s and 00s.' },
|
|
{ title: 'ZWEI MUSIC TELEVISION', text: 'covers the area of current pop music as well as old, well-known hits. Daily playlists are enriched with the current pop hits, Latin American music with a touch of reggaeton provides daily variety.' },
|
|
{ title: 'ADRIA MUSIC TELEVISION', text: 'is aimed at residents of Germany, Austria and Switzerland with Slovenian, Croatian, Bosnian, Herzegovinian, Serbian, Macedonian or Montenegrin roots. The traditional Dalmatian music, Klape, the most famous pop and rock hits are able to inspire everyone from the Adriatic region.' },
|
|
{ title: 'FOLX SLOVENIJA MUSIC TELEVISION',text: 'covers the area of European folk music with exclusive content, 90% of the broadcast content was created or is created in our production FOLX NETWORK.' },
|
|
{ title: 'ONE ADRIA MUSIC TELEVISION', text: 'covers the area of new fresh music from the Adriatic (SLO, CRO, BIH, SRB, MKD, MG), world pop and rock production and adds world hits of the 80s, 90s and 00s.' },
|
|
];
|
|
|
|
const PLAY_SVG = `<svg aria-hidden="true" viewBox="0 0 448 512"><path fill="currentColor" d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"/></svg>`;
|
|
|
|
// pick a random background on each load
|
|
const bgs = ['bg_01','bg_02','bg_03','bg_04','bg_05','bg_06','bg_07'];
|
|
const BG_URL = `/backgrounds/${bgs[Math.floor(Math.random() * bgs.length)]}.jpg`;
|
|
|
|
// ---------- helpers ----------
|
|
function el(html) {
|
|
const t = document.createElement('template');
|
|
t.innerHTML = html.trim();
|
|
return t.content.firstElementChild;
|
|
}
|
|
function escapeHtml(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, c => ({
|
|
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
|
}[c]));
|
|
}
|
|
async function api() { return null; /* no backend */ }
|
|
|
|
// ---------- shell ----------
|
|
function renderShell() {
|
|
document.getElementById('root').innerHTML = `
|
|
<div class="mainBackground" style="background-image: url('${BG_URL}');">
|
|
<div class="nav">
|
|
<div class="nav__width">
|
|
<div class="logo">
|
|
<a href="/" data-link><img alt="Folx.network logo" src="/logos/folxnetwork.png"></a>
|
|
</div>
|
|
<div class="links">
|
|
<a href="/" data-link data-route="/">Home</a>
|
|
<a href="/artists" data-link data-route="/artists">Artists</a>
|
|
<a href="/folxnetwork" data-link data-route="/folxnetwork">About</a>
|
|
<a href="/policies" data-link data-route="/policies">Terms of service</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<main id="main"></main>
|
|
<footer class="siteFooter">© FOLX NETWORK · folxplay.tv</footer>
|
|
</div>
|
|
`;
|
|
// intercept <a data-link> for client-side navigation
|
|
document.body.addEventListener('click', (e) => {
|
|
const a = e.target.closest('a[data-link]');
|
|
if (!a) return;
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
|
|
e.preventDefault();
|
|
navigate(a.getAttribute('href'));
|
|
});
|
|
window.addEventListener('popstate', () => render(location.pathname));
|
|
}
|
|
|
|
function navigate(path) {
|
|
if (location.pathname !== path) history.pushState({}, '', path);
|
|
render(path);
|
|
}
|
|
|
|
// ---------- pages ----------
|
|
const pages = {
|
|
'/': renderHome,
|
|
'/artists': renderArtists,
|
|
'/folxnetwork': renderAbout,
|
|
'/policies': renderPolicies,
|
|
'/log': renderLog,
|
|
};
|
|
|
|
function render(path) {
|
|
const handler = pages[path] || renderNotFound;
|
|
// mark active link
|
|
document.querySelectorAll('.nav .links a').forEach(a => {
|
|
a.classList.toggle('active', a.getAttribute('data-route') === path);
|
|
});
|
|
handler();
|
|
}
|
|
|
|
// ===== HOME =====
|
|
let shakaInstance = 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() {
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `
|
|
<div class="pageHome">
|
|
<div class="split">
|
|
<div class="split__video">
|
|
<div id="player-area"></div>
|
|
<div id="currently-playing" class="currentlyPlaying" style="display:none;"></div>
|
|
</div>
|
|
<div class="split__channels">
|
|
<div class="channelList">
|
|
${CHANNELS.map(c => `
|
|
<a class="channelList__channel" href="#" data-channel="${c.key}">
|
|
<div class="channelList__channelIcon" style="--var-bgColor: ${c.color};">
|
|
${PLAY_SVG}
|
|
</div>
|
|
<div class="channelList__channelLogo">
|
|
<img src="${c.logo}" alt="${escapeHtml(c.alt)}">
|
|
</div>
|
|
</a>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
showSelectChannelBox();
|
|
|
|
// channel click handlers (sidebar)
|
|
main.querySelectorAll('[data-channel]').forEach(a => {
|
|
a.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const key = a.getAttribute('data-channel');
|
|
playChannel(key);
|
|
});
|
|
});
|
|
}
|
|
|
|
function showSelectChannelBox() {
|
|
document.getElementById('player-area').innerHTML = `
|
|
<div class="selectChannelBox">
|
|
<div>
|
|
<div class="lineHeader">FolxPlay Live TV</div>
|
|
<div class="lineSelectChannel">Select channel on right</div>
|
|
<div class="lineRandom" id="random-btn">Or ${PLAY_SVG} random channel</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.getElementById('random-btn')?.addEventListener('click', () => {
|
|
const ch = CHANNELS[Math.floor(Math.random() * CHANNELS.length)];
|
|
playChannel(ch.key);
|
|
});
|
|
}
|
|
|
|
function showVideoPlayer(ch) {
|
|
const bg = ch ? ch.color : '#000';
|
|
const logo = ch ? ch.logo : '';
|
|
document.getElementById('player-area').innerHTML = `
|
|
<div class="split__video_ratio" style="position:relative;">
|
|
<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>
|
|
`;
|
|
}
|
|
|
|
// 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) {
|
|
const ch = CHANNELS.find(c => c.key === key);
|
|
if (!ch) return;
|
|
currentChannel = ch;
|
|
const token = ++playToken; // anything older than this is stale
|
|
clearLiveRetry();
|
|
destroyHls();
|
|
promoFallbackActive = false;
|
|
|
|
// mark active in sidebar
|
|
document.querySelectorAll('.channelList__channelIcon').forEach(el => {
|
|
el.classList.remove('channelList__channelIconActive');
|
|
});
|
|
const link = document.querySelector(`.channelList__channel[data-channel="${key}"] .channelList__channelIcon`);
|
|
if (link) link.classList.add('channelList__channelIconActive');
|
|
|
|
showVideoPlayer(ch);
|
|
await waitForShaka();
|
|
if (token !== playToken) return; // user switched channel meanwhile
|
|
|
|
// Hide the logo overlay as soon as real playback begins (any source).
|
|
const vid = document.getElementById('video');
|
|
if (vid) vid.addEventListener('playing', hideLoadingOverlay, { once: true });
|
|
|
|
if (ch.live) {
|
|
startLive(ch, token);
|
|
} else {
|
|
startPromo(ch, token); // promo-only channels (Folx Slo, One Adria)
|
|
}
|
|
}
|
|
|
|
// ---- LIVE: play the channel's live HLS via hls.js (handles the deep ~90s
|
|
// live edge these channels sit at, which Shaka stalls on). On any
|
|
// failure/stall, drop to the looping promo and retry live in background.
|
|
let hlsInstance = null;
|
|
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);
|
|
}
|
|
}
|
|
|
|
function waitForShaka() {
|
|
return new Promise(resolve => {
|
|
if (window.shaka) return resolve();
|
|
const i = setInterval(() => {
|
|
if (window.shaka) { clearInterval(i); resolve(); }
|
|
}, 50);
|
|
setTimeout(() => { clearInterval(i); resolve(); }, 4000);
|
|
});
|
|
}
|
|
|
|
async function loadCurrentlyPlaying() { /* no-op (no backend) */ }
|
|
async function loadTop10() { /* no-op (no backend) */ }
|
|
|
|
// ===== ARTISTS =====
|
|
function renderArtists() {
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `
|
|
<div class="box">
|
|
<h1>Artists on FolxPlay</h1>
|
|
<p>Six channels of original folk, schlager and pop production from FOLX NETWORK. Pick a channel below to watch the promo.</p>
|
|
<div class="artistsList">
|
|
${CHANNELS.map(c => `
|
|
<a href="#" data-channel-link="${c.key}">
|
|
<strong style="color:${c.color};">●</strong> ${escapeHtml(c.name)}
|
|
</a>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
main.querySelectorAll('[data-channel-link]').forEach(a => {
|
|
a.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
navigate('/');
|
|
// wait one tick so home renders, then play
|
|
setTimeout(() => playChannel(a.getAttribute('data-channel-link')), 50);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ===== ABOUT (folxnetwork) =====
|
|
function renderAbout() {
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `
|
|
<div class="box">
|
|
<div>
|
|
${ABOUT.map(c => `
|
|
<div class="channel">
|
|
<div class="channel__title">${escapeHtml(c.title)}</div>
|
|
<p>${escapeHtml(c.text)}</p>
|
|
</div>
|
|
`).join('')}
|
|
<hr>
|
|
<a href="/log" data-link>Channel PlayLog</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ===== POLICIES =====
|
|
function renderPolicies() {
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `
|
|
<div class="box">
|
|
<h1>Terms of service</h1>
|
|
<p>These Terms of Service ("Account Terms") govern your use of the FolxPlay platform and services, and certain purchases and rentals made through the FolxPlay Channel Store, including videos, audio, graphics, photographs, text and product information ("Original Content") provided by publishers ("Publishers").</p>
|
|
|
|
<h3>Definitions</h3>
|
|
<p><strong>"Channel"</strong> constitutes the entirety of content published, marketed and made available to you by Publishers or on a Publisher's behalf.</p>
|
|
<p><strong>"Original Content"</strong> includes video, audio, graphics, photographs, text and product information.</p>
|
|
<p><strong>"Publisher Service"</strong> means a service Publishers make available through the FolxPlay platform.</p>
|
|
<p><strong>"Member"</strong> refers to anyone that uses the service, also referred to herein as "you".</p>
|
|
|
|
<h3>Changes to Terms of Service</h3>
|
|
<p>FolxPlay may amend the Terms of Service at any time by posting the amended Terms on the FolxPlay website or via the FolxPlay platform, whichever occurs first. By continuing to use the service after the amended Terms are posted, you agree to the amended Terms.</p>
|
|
|
|
<h3>Privacy Policy and Use of Data</h3>
|
|
<p>The FolxPlay Privacy Policy explains how the service collects, uses, transmits, and discloses information you provide.</p>
|
|
|
|
<h3>Fees and Charges</h3>
|
|
<p>FolxPlay does not take part in any commercial activity between Members and Publishers. Fees required to access content will be charged to the payment method on file (credit card or PayPal).</p>
|
|
|
|
<h3>Content Availability</h3>
|
|
<p>The content viewed through the FolxPlay service is solely for your personal and non-commercial enjoyment. Content is protected by copyright and other intellectual property laws and treaties.</p>
|
|
<p>You only have access to channels and content authorized for the country of your residence. Channels and content will vary by geographic location or country.</p>
|
|
|
|
<h3>Children and Age-restricted Content</h3>
|
|
<p>The service contains content which may not be appropriate for children. You are responsible for ensuring that any age-restricted content is not viewed by any person not meeting the applicable age limits without consent from a parent or guardian.</p>
|
|
<p>Age ratings consist of five categories: Kids (All), Older Kids (7+), Teens (13+), Young Adults (16+), Adults (18+).</p>
|
|
|
|
<h3>Service Updates</h3>
|
|
<p>FolxPlay reserves the right to update the service, including bug fixes and updates, at any time and without notice.</p>
|
|
|
|
<h3>Limitation of Liability</h3>
|
|
<p>The FolxPlay service is provided "as is" and "as available" with all faults and without warranty of any kind. To the maximum extent permissible by law, FolxPlay does not guarantee, represent, or warrant that the service will be uninterrupted, secure, virus-free or error-free.</p>
|
|
|
|
<h3>Contact Information</h3>
|
|
<p>If you wish to contact us, please send your correspondence to <a href="mailto:office@folx.tv">office@folx.tv</a>.</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ===== PLAYLOG =====
|
|
function renderLog() {
|
|
const main = document.getElementById('main');
|
|
main.innerHTML = `
|
|
<div class="box">
|
|
<h1>Channel PlayLog</h1>
|
|
<p>The full per-channel play history is available on the production site at
|
|
<a href="https://folxplay.tv/log">folxplay.tv/log</a>.</p>
|
|
<p>This preview at <strong>folxplay.tv</strong> ships the look and feel of the site together with the channel promos served from JW Player Cloud.</p>
|
|
<hr>
|
|
<p><a href="/" data-link>← Back to Home</a></p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderNotFound() {
|
|
document.getElementById('main').innerHTML = `
|
|
<div class="box"><h1>Page not found</h1><p>That page does not exist.</p></div>
|
|
`;
|
|
}
|
|
|
|
// ---------- boot ----------
|
|
renderShell();
|
|
render(location.pathname);
|