diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html
index 0d2e0e3..c0793ba 100644
--- a/fishtank-dashboard.html
+++ b/fishtank-dashboard.html
@@ -2175,6 +2175,7 @@
+
@@ -2873,8 +2874,9 @@
["West Wing", "hwup-5"],
["Jungle Room", "br4j-5"],
["Computer Lab", "bbcl-5"],
- ["Cameraman", "cameraman2-5"],
["Job Board", "jobb-5"],
+ ["Arena", "bare-5"],
+ ["Cameraman", "cameraman2-5"],
];
// Navigation polygons per camera slug
@@ -2988,6 +2990,8 @@
const DEFAULT_IDX = 1; // Director Mode
const hlsInstances = {};
let featuredIdx = DEFAULT_IDX;
+ let ytSourceActive = true;
+ let ytReadyCount = 0;
// directorCell = which grid index currently shows the Director stream (null = director is in featured)
let directorCell = null;
@@ -3019,7 +3023,15 @@
fragLoadingMaxRetry: 3,
manifestLoadingMaxRetry: 3,
});
- hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8');
+ const isYt = ytSourceActive && slug !== '__epyc__';
+ if (isYt) {
+ hls.config.startPosition = -1;
+ hls.config.liveSyncDurationCount = 2;
+ }
+ const hlsSource = isYt
+ ? 'http://localhost:3000/yt-stream/' + slug
+ : 'http://localhost:3000/cam/' + slug + '/index.m3u8';
+ hls.loadSource(hlsSource);
hls.attachMedia(video);
let reconnectOverlayTimer = null;
@@ -3878,7 +3890,14 @@
};
const hls = new Hls({ maxBufferLength: 2, maxMaxBufferLength: 4 });
- hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8');
+ const ytSlugsSet = new Set(['dirc-5','foyr-5','gsrm-5','mrke-5','mrke2-5','hwdn-5',
+ 'dmrm-5','dmrm2-5','dmcl-5','jckz-5','brrr-5','brrr2-5','brpz-5','ktch-5',
+ 'dnrm-5','hwup-5','bbcl-5','br4j-5','bkny-5','codr-5','cfsl-5','jobb-5','bare-5']);
+ if (ytSourceActive && !ytSlugsSet.has(slug)) { hls.destroy(); resolve(); return; }
+ const thumbUrl = ytSourceActive
+ ? 'http://localhost:3000/yt-stream/' + slug
+ : 'http://localhost:3000/cam/' + slug + '/index.m3u8';
+ hls.loadSource(thumbUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
video.addEventListener('timeupdate', capture, { once: true });
@@ -3924,6 +3943,7 @@
const featVideo = document.createElement('video');
featVideo.playsInline = true;
featVideo.autoplay = true;
+ featVideo.crossOrigin = 'anonymous';
const featLabel = document.createElement('div');
featLabel.className = 'cam-label';
featLabel.textContent = CAMERAS[DEFAULT_IDX][0].toUpperCase();
@@ -3962,6 +3982,7 @@
cell.className = 'cam-cell';
cell.id = 'cam-' + i;
cell.dataset.slug = slug;
+ cell.dataset.idx = i;
const canvas = document.createElement('canvas');
canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;';
canvas.id = 'canvas-' + i;
@@ -4228,6 +4249,12 @@
}
}
+ if (msg._ft === 'yt_status') {
+ ytReadyCount = msg.ready || 0;
+ updateYtBtn();
+ return;
+ }
+
if (msg._ft === 'raw') {
console.log('[FT-WS RAW]', msg.data);
}
@@ -4400,6 +4427,67 @@
if (inlineChatAutoScroll) inlineFeed.scrollTop = inlineFeed.scrollHeight;
}
+ // ── YouTube source toggle ────────────────────────────────────
+ fetch('http://localhost:3000/yt-status').then(r => r.json()).then(d => {
+ ytReadyCount = d.ready || 0;
+ updateYtBtn();
+ }).catch(() => {});
+
+ function updateYtBtn() {
+ const btn = document.getElementById('ytToggleBtn');
+ if (!btn) return;
+ if (ytSourceActive) {
+ btn.style.borderColor = '#e74c3c';
+ btn.style.color = '#e74c3c';
+ btn.textContent = '▶ YT LIVE';
+ } else if (ytReadyCount > 0) {
+ btn.style.borderColor = 'var(--green)';
+ btn.style.color = 'var(--green)';
+ btn.textContent = '▶ YT (' + ytReadyCount + ')';
+ } else {
+ btn.style.borderColor = 'var(--muted)';
+ btn.style.color = 'var(--muted)';
+ btn.textContent = '▶ YT SOURCE';
+ }
+ }
+
+ window.toggleYtSource = function() {
+ ytSourceActive = !ytSourceActive;
+ updateYtBtn();
+
+ // Switch featured cam — makeHls now reads ytSourceActive
+ const wrap = document.getElementById('camFeaturedWrap');
+ const featVideo = wrap && wrap.querySelector('video');
+ const featSlug = CAMERAS[featuredIdx] && CAMERAS[featuredIdx][1];
+ if (featVideo && featSlug) {
+ if (hlsInstances['featured']) hlsInstances['featured'].destroy();
+ hlsInstances['featured'] = makeHls(featSlug, featVideo, false);
+ }
+
+ // Dim grid cells that have no YT source
+ const NO_YT = new Set(['cameraman2-5','br3g-5']);
+ const ytSlugs = new Set(['dirc-5','foyr-5','gsrm-5','mrke-5','mrke2-5','hwdn-5',
+ 'dmrm-5','dmrm2-5','dmcl-5','jckz-5','brrr-5','brrr2-5','brpz-5','ktch-5',
+ 'dnrm-5','hwup-5','bbcl-5','br4j-5','bkny-5','codr-5','cfsl-5','bare-5','jobb-5']);
+ const grid = document.getElementById('cameraGrid');
+ if (grid) {
+ grid.querySelectorAll('.cam-cell').forEach(cell => {
+ const slug = cell.dataset.slug;
+ if (ytSourceActive && slug && !ytSlugs.has(slug)) {
+ cell.style.opacity = '0.25';
+ cell.style.pointerEvents = 'none';
+ } else {
+ cell.style.opacity = '';
+ cell.style.pointerEvents = '';
+ }
+ });
+ }
+
+ // Thumbnails are canvas-based — just refresh them from new source
+ refreshAllThumbnails();
+ };
+ // ── End YouTube source toggle ─────────────────────────────────
+
// ── Floor plan ───────────────────────────────────────────────
const FLOOR_ROOMS = {
down: [
@@ -4553,6 +4641,7 @@
cell.className = 'cam-cell';
cell.id = 'cam-' + i;
cell.dataset.slug = slug;
+ cell.dataset.idx = i;
const canvas = document.createElement('canvas');
canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;';
canvas.id = 'canvas-' + i;
diff --git a/server.js b/server.js
index 07c284c..e4fd096 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,131 @@ const PORT = 3000;
const API_TARGET = 'api.fishtank.live';
const CAM_TARGET = 'epyc.goran.jetzt';
const CAM_PORT = 443;
+
+// ── YouTube live stream proxy ────────────────────────────────
+// Per-slug YouTube video IDs
+const YT_VIDEO_IDS = {
+ 'dirc-5': 'lYK5M3PCnNg',
+ 'foyr-5': 'MvaKWOQRHkA',
+ 'gsrm-5': 'TYPEH85q3JU',
+ 'mrke-5': 'm8BoYX8MRxQ',
+ 'mrke2-5': 'VKactnWtMLU',
+ 'hwdn-5': 'PFGmM_L63O4',
+ 'dmrm-5': 'TohEVS4CYn0',
+ 'dmrm2-5': '_3EdEfoyhtI',
+ 'dmcl-5': 'eWRFlT9m94c',
+ 'jckz-5': 'q4C-ePNmuEU',
+ 'brrr-5': 'RCqC9H10HsE',
+ 'brrr2-5': '5NB7X9QJRtA',
+ 'brpz-5': '8vacKvkKI0U',
+ 'ktch-5': 'DfIYesgiGfY',
+ 'dnrm-5': 's2xzD6V4mKI',
+ 'hwup-5': 'k6MDn6y9Jjs',
+ 'bbcl-5': 'bJpr7Ueutag',
+ 'br4j-5': 'u1Zcyl5zrN4',
+ 'bkny-5': 'JWJDpCQyNh8',
+ 'codr-5': 'b1M083H1pis',
+ 'cfsl-5': 'HB7KvlwP2Rs',
+ 'bare-5': 'UcpOf1t-Sh0',
+ 'jobb-5': 'hBJtFeLXOi4',
+};
+
+const ytManifests = {}; // slug -> current manifest URL
+const ytRefreshing = {}; // slug -> bool
+const ytTimers = {}; // slug -> refresh timer
+
+function refreshYtSlug(slug, callback) {
+ const videoId = YT_VIDEO_IDS[slug];
+ if (!videoId) { if (callback) callback(new Error('no video ID for ' + slug)); return; }
+ if (ytRefreshing[slug]) { if (callback) callback(new Error('refresh in progress')); return; }
+ ytRefreshing[slug] = true;
+ execFile('yt-dlp', [
+ '--no-warnings',
+ '-f', '95/93/91/hls-1080/hls-720/hls-480/hls-360/best',
+ '--no-playlist',
+ '-g',
+ 'https://www.youtube.com/watch?v=' + videoId
+ ], { shell: true, timeout: 30000 }, (err, stdout) => {
+ ytRefreshing[slug] = false;
+ if (err) { if (callback) callback(err); return; }
+ const url = stdout.trim().split('\n')[0];
+ if (!url || !url.startsWith('http')) { if (callback) callback(new Error('no URL')); return; }
+ console.log('[YT]', slug, 'url type:', url.includes('.m3u8') ? 'HLS' : url.includes('manifest') ? 'manifest' : 'direct', url.slice(0, 80));
+ ytManifests[slug] = url;
+ if (ytTimers[slug]) clearTimeout(ytTimers[slug]);
+ ytTimers[slug] = setTimeout(() => refreshYtSlug(slug), 4 * 60 * 60 * 1000);
+ if (callback) callback(null, url);
+ });
+}
+
+function refreshAllYt() {
+ const slugs = Object.keys(YT_VIDEO_IDS);
+ console.log('[YT] Refreshing all', slugs.length, 'streams...');
+ let done = 0;
+ slugs.forEach(slug => {
+ refreshYtSlug(slug, (err) => {
+ done++;
+ if (!err) console.log('[YT] Ready:', slug);
+ const ready = slugs.filter(s => !!ytManifests[s]).length;
+ broadcast({ _ft: 'yt_status', ready, total: slugs.length });
+ });
+ });
+}
+
+function proxyYtUrl(targetUrl, res) {
+ let parsedUrl;
+ try { parsedUrl = new URL(targetUrl); } catch(e) { res.writeHead(400); res.end('bad url'); return; }
+ const opts = {
+ hostname: parsedUrl.hostname, port: 443,
+ path: parsedUrl.pathname + parsedUrl.search, method: 'GET',
+ headers: { 'user-agent': 'Mozilla/5.0', 'accept': '*/*' },
+ };
+ const req = https.request(opts, (upstream) => {
+ const ct = upstream.headers['content-type'] || '';
+ // Buffer first chunk to sniff if it's a playlist — don't rely on URL
+ const chunks = [];
+ let decided = false;
+ let isPlaylist = null;
+
+ function decide(firstChunk) {
+ if (decided) return;
+ decided = true;
+ // Check if content starts with #EXTM3U
+ isPlaylist = firstChunk.toString('utf8', 0, 7) === '#EXTM3U' || ct.includes('mpegurl');
+ if (!isPlaylist) {
+ // Stream as binary
+ const headers = { 'access-control-allow-origin': '*', 'cache-control': 'no-cache' };
+ if (ct) headers['content-type'] = ct;
+ res.writeHead(upstream.statusCode, headers);
+ for (const c of chunks) res.write(c);
+ upstream.pipe(res);
+ }
+ }
+
+ upstream.on('data', c => {
+ chunks.push(c);
+ if (!decided) decide(chunks[0]);
+ });
+ upstream.on('end', () => {
+ if (!decided) decide(chunks[0] || Buffer.alloc(0));
+ if (!isPlaylist) return; // already piped
+ // Rewrite playlist URLs
+ let text = Buffer.concat(chunks).toString('utf8');
+ text = text.split('\n').map(line => {
+ const t = line.trim();
+ if (!t || t.startsWith('#')) return line;
+ try {
+ const fullUrl = t.startsWith('http') ? t : new URL(t, targetUrl).href;
+ return 'http://localhost:' + PORT + '/yt-seg?url=' + encodeURIComponent(fullUrl);
+ } catch(e) { return line; }
+ }).join('\n');
+ res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' });
+ res.end(text);
+ });
+ });
+ req.on('error', err => { if (!res.headersSent) { res.writeHead(502); res.end(err.message); } });
+ req.end();
+}
const FT_WS_URL = 'wss://ws.fishtank.live/socket.io/?EIO=4&transport=websocket';
// ── Local WS clients (dashboard connections) ────────────────
@@ -426,6 +551,71 @@ const server = http.createServer((req, res) => {
}); return;
}
+ // YouTube stream endpoints
+ // /yt-refresh — trigger full refresh of all YT manifests
+ if (parsed.pathname === '/yt-refresh' && req.method === 'POST') {
+ refreshAllYt();
+ res.writeHead(200, { 'Content-Type': 'application/json', 'access-control-allow-origin': '*' });
+ res.end(JSON.stringify({ ok: true, slugs: Object.keys(YT_VIDEO_IDS).length }));
+ return;
+ }
+
+ // /yt-status — report which slugs have manifests ready
+ if (parsed.pathname === '/yt-status') {
+ const ready = Object.keys(YT_VIDEO_IDS).filter(s => !!ytManifests[s]);
+ res.writeHead(200, { 'Content-Type': 'application/json', 'access-control-allow-origin': '*' });
+ res.end(JSON.stringify({ ready: ready.length, total: Object.keys(YT_VIDEO_IDS).length, slugs: ready }));
+ return;
+ }
+
+ // /yt-stream/:slug — serve manifest for a specific camera
+ if (parsed.pathname.startsWith('/yt-stream/')) {
+ const slug = parsed.pathname.replace('/yt-stream/', '');
+ if (!ytManifests[slug]) {
+ // Try to fetch on demand if not cached yet
+ if (YT_VIDEO_IDS[slug] && !ytRefreshing[slug]) {
+ refreshYtSlug(slug, (err, url) => {
+ if (err || !url) { res.writeHead(503); res.end('YT stream not ready for ' + slug); return; }
+ proxyYtUrl(url, res);
+ });
+ } else {
+ res.writeHead(503); res.end('YT stream not ready for ' + slug);
+ }
+ return;
+ }
+ proxyYtUrl(ytManifests[slug], res);
+ return;
+ }
+
+ // /yt-debug/:slug — return the raw manifest URL for testing in VLC/browser
+ if (parsed.pathname.startsWith('/yt-debug/')) {
+ const slug = parsed.pathname.replace('/yt-debug/', '');
+ const manifest = ytManifests[slug];
+ const videoId = YT_VIDEO_IDS[slug];
+ res.writeHead(200, { 'Content-Type': 'text/plain', 'access-control-allow-origin': '*' });
+ const lines = [
+ 'slug: ' + slug,
+ 'videoId: ' + (videoId || 'unknown'),
+ 'ytUrl: https://www.youtube.com/watch?v=' + (videoId || '?'),
+ 'manifestUrl: ' + (manifest || 'NOT READY'),
+ 'proxyUrl: http://localhost:3000/yt-stream/' + slug,
+ ];
+ res.end(lines.join('\n'));
+ return;
+ }
+
+ // /yt-seg — proxy individual segments/playlists
+ if (parsed.pathname === '/yt-seg') {
+ let targetUrl;
+ try {
+ const fullParsed = new URL(req.url, 'http://localhost');
+ targetUrl = fullParsed.searchParams.get('url');
+ } catch(e) {}
+ if (!targetUrl) { res.writeHead(400); res.end('missing url'); return; }
+ proxyYtUrl(targetUrl, res);
+ return;
+ }
+
if (parsed.pathname === '/strip-audio') {
if (req.method === 'POST') {
// Accept webm upload, strip audio with ffmpeg, return result
@@ -580,4 +770,5 @@ server.listen(PORT, () => {
console.log('✓ Dashboard → http://localhost:' + PORT);
console.log('\nPress Ctrl+C to stop\n');
connectFishtankWS(null);
+ refreshAllYt();
});