Restore Twitch-style player + Bunny token signing on our folxlive zone

This commit is contained in:
Sebastjan 2026-04-25 18:24:30 +02:00
parent 4caa948d98
commit b91870106b
2 changed files with 202 additions and 5 deletions

View File

@ -1,10 +1,26 @@
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const express = require('express'); const express = require('express');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const STREAM_URL = process.env.HLS_URL || 'https://folxplay.b-cdn.net/live/stream1_master.m3u8'; // Our Bunny CDN pull zone (separate from Rok's folxplay zone)
const BUNNY_HOST = 'https://folxlive.b-cdn.net';
const BUNNY_KEY = process.env.BUNNY_SECURITY_KEY || '492fb7b3-8a1d-4a78-8e9e-fae8c07af195';
function signBunnyUrl(urlPath, expiresInSeconds = 3600) {
const expires = Math.floor(Date.now() / 1000) + expiresInSeconds;
const hash = crypto.createHash('sha256').update(BUNNY_KEY + urlPath + expires).digest();
const token = hash.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return { token, expires, url: `${BUNNY_HOST}${urlPath}?token=${token}&expires=${expires}` };
}
// Default: stream1 master (linear, non-DVR)
const DEFAULT_HLS_PATH = process.env.HLS_PATH || '/live/stream1_master.m3u8';
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '..', 'views')); app.set('views', path.join(__dirname, '..', 'views'));
@ -15,18 +31,31 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
})); }));
app.get('/', (_req, res) => { app.get('/', (_req, res) => {
res.render('index', { hlsUrl: STREAM_URL }); const { url } = signBunnyUrl(DEFAULT_HLS_PATH, 3600);
res.render('index', { hlsUrl: url });
}); });
app.get('/test-embed', (_req, res) => { app.get('/test-embed', (_req, res) => {
res.render('test-embed'); res.render('test-embed');
}); });
// Token-signed master proxy: GET /stream/:n/master.m3u8 → signed Bunny URL
// Optional ?dvr=1 for DVR variant
app.get('/stream/:n/master.m3u8', (req, res) => {
const n = req.params.n;
if (!/^[1-6]$/.test(n)) return res.status(400).send('Invalid stream number');
const dvr = req.query.dvr === '1' ? '_dvr' : '';
const streamPath = `/live/stream${n}_master${dvr}.m3u8`;
const { url } = signBunnyUrl(streamPath, 3600);
res.redirect(302, url);
});
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, hls: STREAM_URL, ts: Date.now() }); res.json({ ok: true, host: BUNNY_HOST, defaultPath: DEFAULT_HLS_PATH, ts: Date.now() });
}); });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`[folx-live] listening on :${PORT}`); console.log(`[folx-live] listening on :${PORT}`);
console.log(`[folx-live] HLS source: ${STREAM_URL}`); console.log(`[folx-live] CDN host: ${BUNNY_HOST}`);
console.log(`[folx-live] Default stream path: ${DEFAULT_HLS_PATH}`);
}); });

View File

@ -506,7 +506,21 @@
</div> </div>
<div class="player-frame" id="playerFrame"> <div class="player-frame" id="playerFrame">
<script src="https://livestream1.viprime.net/embed/stream1_dvr.js"></script> <video id="v" autoplay muted playsinline tabindex="-1"
disablepictureinpicture
controlsList="nodownload nofullscreen noremoteplayback noplaybackrate"></video>
<div class="player-msg visible" id="msg">
<div class="spinner"></div>
<div>Stream wird geladen…</div>
</div>
<div class="player-overlay">
<div class="player-controls">
<button class="pctl" id="btnAudio" aria-label="Ton umschalten" title="Ton umschalten">🔇</button>
<button class="pctl" id="btnFs" aria-label="Vollbild" title="Vollbild">⛶</button>
</div>
</div>
</div> </div>
</main> </main>
@ -617,5 +631,159 @@
</div> </div>
</footer> </footer>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.17/dist/hls.min.js"></script>
<script>
(function () {
const HLS_URL = <%- JSON.stringify(hlsUrl) %>;
const video = document.getElementById('v');
const msg = document.getElementById('msg');
const playerFrame = document.getElementById('playerFrame');
let hls = null;
function showMsg(html) { msg.innerHTML = html; msg.classList.add('visible'); }
function hideMsg() { msg.classList.remove('visible'); }
function attach() {
try { hls && hls.destroy(); } catch (e) {}
hls = null;
if (video.canPlayType('application/vnd.apple.mpegurl')) {
// iOS Safari — native HLS
video.src = HLS_URL;
video.play().catch(() => {});
video.addEventListener('playing', hideMsg, { once: true });
return;
}
if (window.Hls && window.Hls.isSupported()) {
hls = new Hls({
liveDurationInfinity: true,
liveSyncDurationCount: 4,
backBufferLength: 30,
maxBufferLength: 60,
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 6,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
// ABR — adaptive bitrate switching
startLevel: -1,
capLevelToPlayerSize: false,
abrEwmaDefaultEstimate: 3000000,
testBandwidth: true,
abrBandWidthFactor: 0.95,
abrBandWidthUpFactor: 0.7,
});
hls.loadSource(HLS_URL);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
hideMsg();
video.play().catch(() => setTimeout(() => video.play().catch(() => {}), 200));
});
hls.on(Hls.Events.ERROR, (_e, data) => {
if (!data.fatal) return;
showMsg('<div class="spinner"></div><div>Verbindung wird wiederhergestellt…</div>');
setTimeout(() => { attach(); }, 3000);
});
} else {
showMsg('<div>Stream wird in diesem Browser nicht unterstützt</div>');
}
}
attach();
// Twitch-style: pause is disabled. If anything pauses the video, resume + seek to live edge.
function seekToLive() {
try {
if (hls && hls.liveSyncPosition) {
video.currentTime = hls.liveSyncPosition;
} else if (video.seekable && video.seekable.length > 0) {
video.currentTime = video.seekable.end(video.seekable.length - 1);
}
} catch (e) {}
}
video.addEventListener('pause', () => {
setTimeout(() => {
seekToLive();
video.play().catch(() => {});
}, 80);
});
video.addEventListener('seeked', () => {
if (!hls) return;
if (hls.liveSyncPosition && video.currentTime < hls.liveSyncPosition - 10) {
video.currentTime = hls.liveSyncPosition;
}
});
video.addEventListener('contextmenu', (e) => e.preventDefault());
video.disablePictureInPicture = true;
video.setAttribute('disablePictureInPicture', 'true');
try { video.disableRemotePlayback = true; } catch (e) {}
// Block keyboard shortcuts that pause
document.addEventListener('keydown', (e) => {
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === ' ' || e.key === 'Spacebar' || e.code === 'Space' ||
e.key === 'k' || e.key === 'K' ||
e.key === 'MediaPlayPause' || e.key === 'MediaPause') {
e.preventDefault();
if (video.paused) {
seekToLive();
video.play().catch(() => {});
}
}
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
seekToLive();
video.play().catch(() => {});
}
});
// Audio toggle (custom button)
const btnAudio = document.getElementById('btnAudio');
const btnFs = document.getElementById('btnFs');
function updateAudio() {
btnAudio.textContent = video.muted ? '🔇' : '🔊';
btnAudio.title = video.muted ? 'Ton einschalten' : 'Stummschalten';
}
updateAudio();
btnAudio.addEventListener('click', (e) => {
e.stopPropagation();
video.muted = !video.muted;
video.volume = 1.0;
updateAudio();
});
// Fullscreen — handle iOS Safari separately
function enterFullscreen() {
if (typeof video.webkitEnterFullscreen === 'function' && !document.fullscreenEnabled) {
try { video.webkitEnterFullscreen(); return; } catch (e) {}
}
if (video.requestFullscreen) { video.requestFullscreen().catch(() => {}); return; }
if (video.webkitRequestFullscreen) { video.webkitRequestFullscreen(); return; }
if (playerFrame.requestFullscreen) { playerFrame.requestFullscreen().catch(() => {}); return; }
if (playerFrame.webkitRequestFullscreen) { playerFrame.webkitRequestFullscreen(); return; }
if (typeof video.webkitEnterFullscreen === 'function') {
try { video.webkitEnterFullscreen(); } catch (e) {}
}
}
function exitFullscreen() {
if (document.exitFullscreen) { document.exitFullscreen().catch(() => {}); return; }
if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); return; }
if (typeof video.webkitExitFullscreen === 'function') { try { video.webkitExitFullscreen(); } catch (e) {} }
}
function isFullscreen() {
return !!(document.fullscreenElement || document.webkitFullscreenElement || video.webkitDisplayingFullscreen);
}
btnFs.addEventListener('click', (e) => {
e.stopPropagation();
if (isFullscreen()) exitFullscreen();
else enterFullscreen();
});
video.addEventListener('click', () => { video.muted = !video.muted; video.volume = 1.0; updateAudio(); });
video.addEventListener('dblclick', (e) => { e.preventDefault(); btnFs.click(); });
})();
</script>
</body> </body>
</html> </html>