Restore Twitch-style player + Bunny token signing on our folxlive zone
This commit is contained in:
parent
4caa948d98
commit
b91870106b
@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
170
views/index.ejs
170
views/index.ejs
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user