[ci skip] Move Terraform modules into stack directories
Move all 88 service modules (66 individual + 22 platform) from modules/kubernetes/<service>/ into their corresponding stack directories: - Service stacks: stacks/<service>/module/ - Platform stack: stacks/platform/modules/<service>/ This collocates module source code with its Terragrunt definition. Only shared utility modules remain in modules/kubernetes/: ingress_factory, setup_tls_secret, dockerhub_secret, oauth-proxy. All cross-references to shared modules updated to use correct relative paths. Verified with terragrunt run --all -- plan: 0 adds, 0 destroys across all 68 stacks.
This commit is contained in:
parent
73cb696f12
commit
e225e81ebf
614 changed files with 12075 additions and 352 deletions
1455
stacks/f1-stream/module/files/static/css/custom.css
Normal file
1455
stacks/f1-stream/module/files/static/css/custom.css
Normal file
File diff suppressed because it is too large
Load diff
4
stacks/f1-stream/module/files/static/css/pico.min.css
vendored
Normal file
4
stacks/f1-stream/module/files/static/css/pico.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
205
stacks/f1-stream/module/files/static/index.html
Normal file
205
stacks/f1-stream/module/files/static/index.html
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>F1 Streams</title>
|
||||
<meta name="description" content="Live F1 streaming links aggregated from Reddit and user submissions">
|
||||
<meta property="og:title" content="F1 Streams">
|
||||
<meta property="og:description" content="Live F1 streaming links aggregated from Reddit and user submissions">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect fill='%23e10600' rx='12' width='100' height='100'/><text x='50' y='72' font-size='60' font-weight='900' text-anchor='middle' fill='white' font-family='sans-serif'>F1</text></svg>">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Titillium+Web:wght@400;600;700;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/pico.min.css">
|
||||
<link rel="stylesheet" href="/static/css/custom.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<span class="f1-logo">F1</span>
|
||||
<div>
|
||||
<h1 class="brand-title">Streams</h1>
|
||||
<div class="brand-subtitle">Live Racing Hub</div>
|
||||
</div>
|
||||
<span class="live-indicator" id="live-badge" hidden>
|
||||
<span class="live-dot"></span>
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
<div class="auth-section" id="auth-section">
|
||||
<button id="login-btn" onclick="showAuthDialog()">Login / Register</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="racing-stripe"></div>
|
||||
|
||||
<nav class="tabs" id="tabs">
|
||||
<button class="hamburger" id="hamburger" onclick="toggleMobileNav()" aria-label="Toggle navigation">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="tab-btn active" data-tab="streams" onclick="switchTab('streams')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Streams
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="reddit" onclick="switchTab('reddit')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(90 12 12)"/></svg>
|
||||
Reddit Links
|
||||
</button>
|
||||
<button class="tab-btn hidden" data-tab="mine" onclick="switchTab('mine')" id="tab-mine">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
My Streams
|
||||
</button>
|
||||
<button class="tab-btn hidden" data-tab="admin" onclick="switchTab('admin')" id="tab-admin">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Admin
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section class="tab-content active" id="content-streams">
|
||||
<div class="submit-form-card">
|
||||
<div class="submit-form">
|
||||
<input type="url" id="public-submit-url" placeholder="https://stream-url.com/..." required>
|
||||
<input type="text" id="public-submit-title" placeholder="Stream title (optional)">
|
||||
<button onclick="addPublicStream()">Add Stream</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-grid" id="stream-grid"></div>
|
||||
<div class="empty-state" id="streams-empty" style="display:none">
|
||||
<span class="empty-icon">🏁</span>
|
||||
<div class="empty-title">No Streams Yet</div>
|
||||
<p class="empty-desc">Add a stream URL above to get the race started.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-content" id="content-reddit">
|
||||
<div class="section-header">
|
||||
<h3>Reddit Links</h3>
|
||||
<button onclick="refreshRedditLinks()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<ul class="link-list" id="reddit-list"></ul>
|
||||
<div class="empty-state" id="reddit-empty" style="display:none">
|
||||
<span class="empty-icon">📡</span>
|
||||
<div class="empty-title">No Links Found</div>
|
||||
<p class="empty-desc">No Reddit links scraped yet. Check back closer to race time.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-content" id="content-mine">
|
||||
<div class="submit-form-card">
|
||||
<div class="submit-form">
|
||||
<input type="url" id="submit-url" placeholder="https://stream-url.com/..." required>
|
||||
<input type="text" id="submit-title" placeholder="Stream title (optional)">
|
||||
<button onclick="submitStream()">Submit Stream</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-grid" id="my-stream-grid"></div>
|
||||
<div class="empty-state" id="mine-empty" style="display:none">
|
||||
<span class="empty-icon">🎮</span>
|
||||
<div class="empty-title">Your Pit Lane is Empty</div>
|
||||
<p class="empty-desc">Submit a stream URL above to join the grid.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-content" id="content-admin">
|
||||
<div class="section-header">
|
||||
<h3>All Streams</h3>
|
||||
<button onclick="triggerScrape()">Trigger Scrape</button>
|
||||
</div>
|
||||
<div class="admin-stats" id="admin-stats"></div>
|
||||
<div id="admin-stream-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- Browser Session Viewer (inline within main) -->
|
||||
<section id="browser-viewer" class="browser-viewer hidden">
|
||||
<div class="browser-viewer-bar">
|
||||
<div class="browser-url-bar">
|
||||
<svg class="browser-url-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
<span class="browser-url-text" id="browser-url"></span>
|
||||
</div>
|
||||
<span class="browser-viewer-status"></span>
|
||||
<a id="browser-open-original" href="#" target="_blank" rel="noopener" class="browser-open-btn" title="Open original in new tab">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
<button class="browser-viewer-close" onclick="closeBrowserSession()" title="Close viewer">×</button>
|
||||
</div>
|
||||
<div class="browser-viewer-content">
|
||||
<div class="loading-overlay" id="browser-viewer-loader">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>Stream links are user-submitted and scraped from Reddit. No streams are hosted on this site.</p>
|
||||
</footer>
|
||||
|
||||
<!-- Auth Dialog -->
|
||||
<dialog id="auth-dialog">
|
||||
<article>
|
||||
<button class="dialog-close" onclick="document.getElementById('auth-dialog').close()" aria-label="Close">×</button>
|
||||
<div class="dialog-logo"><span class="f1-logo">F1</span></div>
|
||||
<h3 class="dialog-title">Welcome</h3>
|
||||
<p class="dialog-subtitle">Sign in with your passkey to manage streams</p>
|
||||
|
||||
<div class="dialog-tabs">
|
||||
<button class="dialog-tab-btn active" onclick="switchAuthTab('login', event)">Login</button>
|
||||
<button class="dialog-tab-btn" onclick="switchAuthTab('register', event)">Register</button>
|
||||
</div>
|
||||
|
||||
<div id="auth-login-form" class="auth-form-group">
|
||||
<label for="login-username">Username</label>
|
||||
<input type="text" id="login-username" placeholder="Username" autocomplete="username webauthn">
|
||||
<div class="error-msg" id="login-error"></div>
|
||||
<button onclick="doLogin()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Login with Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="auth-register-form" class="auth-form-group" style="display:none">
|
||||
<label for="register-username">Username</label>
|
||||
<input type="text" id="register-username" placeholder="Username (3-30 chars)" autocomplete="username">
|
||||
<div class="error-msg" id="register-error"></div>
|
||||
<button onclick="doRegister()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
|
||||
Register with Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onclick="document.getElementById('auth-dialog').close()" class="btn-secondary dialog-cancel">Cancel</button>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Reddit Viewer Overlay -->
|
||||
<div id="reddit-viewer" class="reddit-viewer hidden">
|
||||
<div class="reddit-viewer-bar">
|
||||
<span class="reddit-viewer-title"></span>
|
||||
<button class="reddit-viewer-close" onclick="closeRedditViewer()">×</button>
|
||||
</div>
|
||||
<div class="reddit-viewer-content">
|
||||
<div class="loading-overlay" id="reddit-viewer-loader">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Session Viewer (inline, inside main via JS) -->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/crypto-js@4/crypto-js.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest/dist/hls.min.js"></script>
|
||||
<script src="/static/js/player.js"></script>
|
||||
<script src="/static/js/utils.js"></script>
|
||||
<script src="/static/js/auth.js"></script>
|
||||
<script src="/static/js/streams.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
121
stacks/f1-stream/module/files/static/js/app.js
Normal file
121
stacks/f1-stream/module/files/static/js/app.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Toast notification system
|
||||
const TOAST_ICONS = {
|
||||
success: '\u2705',
|
||||
error: '\u274C',
|
||||
warning: '\u26A0\uFE0F',
|
||||
info: '\u2139\uFE0F'
|
||||
};
|
||||
|
||||
function showToast(message, type = 'info', duration = 4000) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.innerHTML = `
|
||||
<span class="toast-icon">${TOAST_ICONS[type] || TOAST_ICONS.info}</span>
|
||||
<span class="toast-message">${escapeHtml(message)}</span>
|
||||
<button class="toast-close" onclick="dismissToast(this.parentElement)">×</button>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(toast), duration);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToast(toast) {
|
||||
if (!toast || toast.classList.contains('toast-out')) return;
|
||||
toast.classList.add('toast-out');
|
||||
toast.addEventListener('animationend', () => toast.remove());
|
||||
}
|
||||
|
||||
// Confirm dialog (replaces window.confirm)
|
||||
function showConfirm(message) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-msg">${escapeHtml(message)}</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="btn-secondary" id="confirm-cancel">Cancel</button>
|
||||
<button class="btn-primary" id="confirm-ok">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.querySelector('#confirm-ok').addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
resolve(true);
|
||||
});
|
||||
overlay.querySelector('#confirm-cancel').addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
resolve(false);
|
||||
});
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
overlay.remove();
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Mobile nav hamburger toggle
|
||||
function toggleMobileNav() {
|
||||
const tabs = document.getElementById('tabs');
|
||||
tabs.classList.toggle('open');
|
||||
}
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tab) {
|
||||
closeRedditViewer();
|
||||
|
||||
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('.tab-content').forEach(c => {
|
||||
c.classList.toggle('active', c.id === 'content-' + tab);
|
||||
});
|
||||
|
||||
// Close mobile nav
|
||||
document.getElementById('tabs').classList.remove('open');
|
||||
|
||||
// Load data for the tab
|
||||
switch (tab) {
|
||||
case 'streams':
|
||||
loadPublicStreams();
|
||||
break;
|
||||
case 'reddit':
|
||||
loadRedditLinks();
|
||||
break;
|
||||
case 'mine':
|
||||
loadMyStreams();
|
||||
break;
|
||||
case 'admin':
|
||||
loadAdminStreams();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
checkAuth();
|
||||
await loadPublicStreams();
|
||||
|
||||
const grid = document.getElementById('stream-grid');
|
||||
const badge = document.getElementById('live-badge');
|
||||
if (badge && grid && grid.children.length > 0) {
|
||||
badge.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Close Reddit viewer on Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const viewer = document.getElementById('reddit-viewer');
|
||||
if (viewer && !viewer.classList.contains('hidden')) {
|
||||
closeRedditViewer();
|
||||
}
|
||||
}
|
||||
});
|
||||
219
stacks/f1-stream/module/files/static/js/auth.js
Normal file
219
stacks/f1-stream/module/files/static/js/auth.js
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// WebAuthn helper: base64url encode/decode
|
||||
function bufToBase64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let str = '';
|
||||
for (const b of bytes) str += String.fromCharCode(b);
|
||||
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
function base64urlToBuf(b64) {
|
||||
const pad = b64.length % 4;
|
||||
if (pad) b64 += '='.repeat(4 - pad);
|
||||
const str = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const buf = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
|
||||
let currentUser = null;
|
||||
|
||||
function showAuthDialog() {
|
||||
document.getElementById('auth-dialog').showModal();
|
||||
}
|
||||
|
||||
function switchAuthTab(tab, evt) {
|
||||
const btns = document.querySelectorAll('.dialog-tab-btn');
|
||||
btns.forEach(b => b.classList.remove('active'));
|
||||
evt.target.classList.add('active');
|
||||
|
||||
document.getElementById('auth-login-form').style.display = tab === 'login' ? 'block' : 'none';
|
||||
document.getElementById('auth-register-form').style.display = tab === 'register' ? 'block' : 'none';
|
||||
document.getElementById('login-error').textContent = '';
|
||||
document.getElementById('register-error').textContent = '';
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
const username = document.getElementById('register-username').value.trim();
|
||||
const errEl = document.getElementById('register-error');
|
||||
errEl.textContent = '';
|
||||
|
||||
if (!username || username.length < 3) {
|
||||
errEl.textContent = 'Username must be at least 3 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Begin registration
|
||||
const beginResp = await fetch('/api/auth/register/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!beginResp.ok) {
|
||||
const err = await beginResp.json();
|
||||
errEl.textContent = err.error || 'Registration failed';
|
||||
return;
|
||||
}
|
||||
|
||||
const options = await beginResp.json();
|
||||
|
||||
// Convert base64url fields to ArrayBuffers
|
||||
options.publicKey.challenge = base64urlToBuf(options.publicKey.challenge);
|
||||
options.publicKey.user.id = base64urlToBuf(options.publicKey.user.id);
|
||||
if (options.publicKey.excludeCredentials) {
|
||||
options.publicKey.excludeCredentials = options.publicKey.excludeCredentials.map(c => ({
|
||||
...c,
|
||||
id: base64urlToBuf(c.id)
|
||||
}));
|
||||
}
|
||||
|
||||
// Step 2: Create credential via browser
|
||||
const credential = await navigator.credentials.create(options);
|
||||
|
||||
// Step 3: Finish registration
|
||||
const attestation = {
|
||||
id: credential.id,
|
||||
rawId: bufToBase64url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: bufToBase64url(credential.response.attestationObject),
|
||||
clientDataJSON: bufToBase64url(credential.response.clientDataJSON)
|
||||
}
|
||||
};
|
||||
|
||||
const finishResp = await fetch(`/api/auth/register/finish?username=${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(attestation)
|
||||
});
|
||||
|
||||
if (!finishResp.ok) {
|
||||
const err = await finishResp.json();
|
||||
errEl.textContent = err.error || 'Registration failed';
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await finishResp.json();
|
||||
setLoggedIn(user);
|
||||
document.getElementById('auth-dialog').close();
|
||||
} catch (e) {
|
||||
console.error('Registration error:', e);
|
||||
errEl.textContent = e.message || 'Registration failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const username = document.getElementById('login-username').value.trim();
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.textContent = '';
|
||||
|
||||
if (!username) {
|
||||
errEl.textContent = 'Username required';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Begin login
|
||||
const beginResp = await fetch('/api/auth/login/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
if (!beginResp.ok) {
|
||||
const err = await beginResp.json();
|
||||
errEl.textContent = err.error || 'Login failed';
|
||||
return;
|
||||
}
|
||||
|
||||
const options = await beginResp.json();
|
||||
|
||||
// Convert base64url fields
|
||||
options.publicKey.challenge = base64urlToBuf(options.publicKey.challenge);
|
||||
if (options.publicKey.allowCredentials) {
|
||||
options.publicKey.allowCredentials = options.publicKey.allowCredentials.map(c => ({
|
||||
...c,
|
||||
id: base64urlToBuf(c.id)
|
||||
}));
|
||||
}
|
||||
|
||||
// Step 2: Get assertion via browser
|
||||
const assertion = await navigator.credentials.get(options);
|
||||
|
||||
// Step 3: Finish login
|
||||
const assertionData = {
|
||||
id: assertion.id,
|
||||
rawId: bufToBase64url(assertion.rawId),
|
||||
type: assertion.type,
|
||||
response: {
|
||||
authenticatorData: bufToBase64url(assertion.response.authenticatorData),
|
||||
clientDataJSON: bufToBase64url(assertion.response.clientDataJSON),
|
||||
signature: bufToBase64url(assertion.response.signature),
|
||||
userHandle: assertion.response.userHandle ? bufToBase64url(assertion.response.userHandle) : ''
|
||||
}
|
||||
};
|
||||
|
||||
const finishResp = await fetch(`/api/auth/login/finish?username=${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(assertionData)
|
||||
});
|
||||
|
||||
if (!finishResp.ok) {
|
||||
const err = await finishResp.json();
|
||||
errEl.textContent = err.error || 'Login failed';
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await finishResp.json();
|
||||
setLoggedIn(user);
|
||||
document.getElementById('auth-dialog').close();
|
||||
} catch (e) {
|
||||
console.error('Login error:', e);
|
||||
errEl.textContent = e.message || 'Login failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
setLoggedOut();
|
||||
}
|
||||
|
||||
function setLoggedIn(user) {
|
||||
currentUser = user;
|
||||
const section = document.getElementById('auth-section');
|
||||
section.innerHTML = `
|
||||
<span>Hi, ${escapeHtml(user.username)}</span>
|
||||
<button onclick="doLogout()">Logout</button>
|
||||
`;
|
||||
document.getElementById('tab-mine').classList.remove('hidden');
|
||||
if (user.is_admin) {
|
||||
document.getElementById('tab-admin').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setLoggedOut() {
|
||||
currentUser = null;
|
||||
const section = document.getElementById('auth-section');
|
||||
section.innerHTML = '<button id="login-btn" onclick="showAuthDialog()">Login / Register</button>';
|
||||
document.getElementById('tab-mine').classList.add('hidden');
|
||||
document.getElementById('tab-admin').classList.add('hidden');
|
||||
// Switch to streams tab if on a protected tab
|
||||
const activeTab = document.querySelector('.tab-btn.active');
|
||||
if (activeTab && (activeTab.dataset.tab === 'mine' || activeTab.dataset.tab === 'admin')) {
|
||||
switchTab('streams');
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const resp = await fetch('/api/auth/me');
|
||||
if (resp.ok) {
|
||||
const user = await resp.json();
|
||||
setLoggedIn(user);
|
||||
}
|
||||
} catch (e) {
|
||||
// Not logged in
|
||||
}
|
||||
}
|
||||
216
stacks/f1-stream/module/files/static/js/player.js
Normal file
216
stacks/f1-stream/module/files/static/js/player.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// player.js — Native HLS player management using HLS.js directly
|
||||
|
||||
var _hlsInstance = null;
|
||||
var _videoElement = null;
|
||||
|
||||
/**
|
||||
* Fetch player config for a stream from the backend.
|
||||
* Returns {type: "hls"|"daddylive"|"proxy", hls_url, auth_token, ...}
|
||||
*/
|
||||
async function getPlayerConfig(streamId) {
|
||||
try {
|
||||
const resp = await fetch('/api/streams/' + streamId + '/player-config');
|
||||
if (!resp.ok) return { type: 'proxy' };
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch player config:', e);
|
||||
return { type: 'proxy' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a /hls/{b64} URL back to the original upstream URL.
|
||||
*/
|
||||
function decodeHLSURL(proxyURL) {
|
||||
if (!proxyURL || typeof proxyURL !== 'string') return proxyURL;
|
||||
var m = proxyURL.match(/\/hls\/([A-Za-z0-9_-]+)/);
|
||||
if (!m) return proxyURL;
|
||||
try {
|
||||
// base64url decode
|
||||
var b64 = m[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
// pad
|
||||
while (b64.length % 4 !== 0) b64 += '=';
|
||||
return atob(b64);
|
||||
} catch (e) {
|
||||
return proxyURL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HLS.js player for a plain HLS stream.
|
||||
*/
|
||||
function createHLSPlayer(containerSelector, hlsURL) {
|
||||
destroyNativePlayer();
|
||||
_buildPlayer(containerSelector, hlsURL, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HLS.js player for DaddyLive streams with auth module integration.
|
||||
*/
|
||||
function createDaddyLivePlayer(containerSelector, config) {
|
||||
destroyNativePlayer();
|
||||
|
||||
if (config.auth_mod_url) {
|
||||
_loadAuthModAndPlay(containerSelector, config);
|
||||
} else {
|
||||
_buildPlayer(containerSelector, config.hls_url, {});
|
||||
}
|
||||
}
|
||||
|
||||
function _loadAuthModAndPlay(containerSelector, config) {
|
||||
var script = document.createElement('script');
|
||||
script.src = config.auth_mod_url;
|
||||
script.onload = function () {
|
||||
_createDaddyLivePlayerWithAuth(containerSelector, config);
|
||||
};
|
||||
script.onerror = function () {
|
||||
console.warn('Failed to load auth module, falling back to direct HLS');
|
||||
_buildPlayer(containerSelector, config.hls_url, {});
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function _createDaddyLivePlayerWithAuth(containerSelector, config) {
|
||||
var hlsConfig = {};
|
||||
|
||||
// If EPlayerAuth is available, set up xhr wrapping
|
||||
if (typeof EPlayerAuth !== 'undefined' && typeof EPlayerAuth.init === 'function') {
|
||||
try {
|
||||
EPlayerAuth.init({
|
||||
authToken: config.auth_token,
|
||||
channelKey: config.channel_key,
|
||||
channelSalt: config.channel_salt,
|
||||
timestamp: config.timestamp,
|
||||
serverKey: config.server_key
|
||||
});
|
||||
|
||||
if (typeof EPlayerAuth.getXhrSetup === 'function') {
|
||||
var origSetup = EPlayerAuth.getXhrSetup();
|
||||
hlsConfig.xhrSetup = function (xhr, url) {
|
||||
// Decode the real upstream URL from our /hls/{b64} proxy path
|
||||
var realURL = decodeHLSURL(url);
|
||||
|
||||
// Create interceptor to capture headers the auth module sets
|
||||
var captured = {};
|
||||
var fakeXHR = {
|
||||
setRequestHeader: function (k, v) { captured[k] = v; }
|
||||
};
|
||||
|
||||
try {
|
||||
origSetup(fakeXHR, realURL);
|
||||
} catch (e) {
|
||||
console.warn('Auth xhrSetup error:', e);
|
||||
}
|
||||
|
||||
// Re-set captured headers with forwarding prefix
|
||||
for (var k in captured) {
|
||||
if (captured.hasOwnProperty(k)) {
|
||||
xhr.setRequestHeader('X-Hls-Forward-' + k, captured[k]);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('EPlayerAuth init failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
_buildPlayer(containerSelector, config.hls_url, hlsConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an HLS.js player with a <video> element.
|
||||
*/
|
||||
function _buildPlayer(containerSelector, hlsURL, extraConfig) {
|
||||
var container = document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
// Create video element
|
||||
var video = document.createElement('video');
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
video.style.width = '100%';
|
||||
video.style.height = '100%';
|
||||
video.style.backgroundColor = '#000';
|
||||
container.appendChild(video);
|
||||
_videoElement = video;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
var config = {
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60
|
||||
};
|
||||
// Merge extra config (e.g. xhrSetup for auth)
|
||||
for (var k in extraConfig) {
|
||||
if (extraConfig.hasOwnProperty(k)) {
|
||||
config[k] = extraConfig[k];
|
||||
}
|
||||
}
|
||||
|
||||
var hls = new Hls(config);
|
||||
hls.loadSource(hlsURL);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function () {
|
||||
video.play().catch(function(e) {
|
||||
console.warn('Autoplay blocked:', e);
|
||||
});
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, function (event, data) {
|
||||
console.error('HLS.js error:', data.type, data.details, data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.warn('HLS network error, attempting recovery...');
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.warn('HLS media error, attempting recovery...');
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.error('HLS fatal error, cannot recover');
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
_hlsInstance = hls;
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari native HLS
|
||||
video.src = hlsURL;
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
video.play().catch(function(e) {
|
||||
console.warn('Autoplay blocked:', e);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
container.textContent = 'HLS playback is not supported in this browser.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the current native player instance.
|
||||
*/
|
||||
function destroyNativePlayer() {
|
||||
if (_hlsInstance) {
|
||||
try {
|
||||
_hlsInstance.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying HLS instance:', e);
|
||||
}
|
||||
_hlsInstance = null;
|
||||
}
|
||||
if (_videoElement) {
|
||||
try {
|
||||
_videoElement.pause();
|
||||
_videoElement.removeAttribute('src');
|
||||
_videoElement.load();
|
||||
_videoElement.remove();
|
||||
} catch (e) {
|
||||
console.warn('Error removing video element:', e);
|
||||
}
|
||||
_videoElement = null;
|
||||
}
|
||||
}
|
||||
419
stacks/f1-stream/module/files/static/js/streams.js
Normal file
419
stacks/f1-stream/module/files/static/js/streams.js
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
async function loadPublicStreams() {
|
||||
const grid = document.getElementById('stream-grid');
|
||||
const empty = document.getElementById('streams-empty');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/streams/public');
|
||||
const streams = await resp.json();
|
||||
|
||||
if (!streams || streams.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
empty.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
grid.innerHTML = streams.map(s => streamCard(s, !!currentUser)).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load streams:', e);
|
||||
grid.innerHTML = '';
|
||||
empty.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyStreams() {
|
||||
const grid = document.getElementById('my-stream-grid');
|
||||
const empty = document.getElementById('mine-empty');
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/streams/mine');
|
||||
const streams = await resp.json();
|
||||
|
||||
if (!streams || streams.length === 0) {
|
||||
grid.innerHTML = '';
|
||||
empty.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
grid.innerHTML = streams.map(s => streamCard(s, true)).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load my streams:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRedditLinks() {
|
||||
const list = document.getElementById('reddit-list');
|
||||
const empty = document.getElementById('reddit-empty');
|
||||
|
||||
try {
|
||||
const [scrapedResp, streamsResp] = await Promise.all([
|
||||
fetch('/api/scraped'),
|
||||
fetch('/api/streams/public')
|
||||
]);
|
||||
const links = await scrapedResp.json();
|
||||
const streams = await streamsResp.json();
|
||||
|
||||
const importedURLs = new Set((streams || []).map(s => s.url));
|
||||
|
||||
if (!links || links.length === 0) {
|
||||
list.innerHTML = '';
|
||||
empty.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
list.innerHTML = links.map(l => {
|
||||
const imported = importedURLs.has(l.url);
|
||||
const actionHtml = imported
|
||||
? `<span class="badge badge-imported">Imported</span>`
|
||||
: `<button class="btn-import" onclick="importRedditLink('${escapeHtml(l.id)}')">Import</button>`;
|
||||
return `
|
||||
<li>
|
||||
<span class="link-source-badge">${escapeHtml(l.source)}</span>
|
||||
<div class="link-title">
|
||||
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener">${escapeHtml(l.title || l.url)}</a>
|
||||
</div>
|
||||
${actionHtml}
|
||||
<a href="${escapeHtml(l.url)}" target="_blank" rel="noopener" class="link-open-icon-wrap" title="Open in new tab">
|
||||
<svg class="link-open-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load Reddit links:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function importRedditLink(id) {
|
||||
try {
|
||||
const resp = await fetch(`/api/scraped/${id}/import`, { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
showToast(err.error || 'Failed to import', 'error');
|
||||
return;
|
||||
}
|
||||
showToast('Stream imported', 'success');
|
||||
loadRedditLinks();
|
||||
loadPublicStreams();
|
||||
} catch (e) {
|
||||
showToast('Failed to import stream', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminStreams() {
|
||||
const container = document.getElementById('admin-stream-list');
|
||||
const statsContainer = document.getElementById('admin-stats');
|
||||
try {
|
||||
const resp = await fetch('/api/admin/streams');
|
||||
const streams = await resp.json();
|
||||
|
||||
if (!streams || streams.length === 0) {
|
||||
statsContainer.innerHTML = '';
|
||||
container.innerHTML = '<div class="empty-state"><span class="empty-icon">📋</span><div class="empty-title">No Streams</div><p class="empty-desc">No streams have been submitted yet.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const total = streams.length;
|
||||
const published = streams.filter(s => s.published).length;
|
||||
const drafts = total - published;
|
||||
|
||||
statsContainer.innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">${total}</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">${published}</div>
|
||||
<div class="stat-label">Published</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">${drafts}</div>
|
||||
<div class="stat-label">Drafts</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = streams.map(s => `
|
||||
<div class="admin-stream">
|
||||
<div class="info">
|
||||
<span class="status-dot ${s.published ? 'published' : 'draft'}"></span>
|
||||
<div class="stream-details">
|
||||
<div class="stream-title">
|
||||
${escapeHtml(s.title)}
|
||||
<span class="badge ${s.published ? 'badge-published' : 'badge-draft'}">
|
||||
${s.published ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stream-url">${escapeHtml(s.url)}</div>
|
||||
${s.submitted_by ? `<div class="stream-submitter">by ${escapeHtml(s.submitted_by)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="togglePublish('${s.id}')" class="${s.published ? 'btn-secondary-sm' : 'btn-primary-sm'}">
|
||||
${s.published ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
<button onclick="deleteStream('${s.id}', true)" class="btn-danger-sm">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
console.error('Failed to load admin streams:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function streamCard(stream, canDelete) {
|
||||
const deleteBtn = canDelete
|
||||
? `<button onclick="event.stopPropagation(); deleteStream('${stream.id}', false)" class="icon-btn danger" title="Delete stream">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="stream-card" data-stream-id="${stream.id}"
|
||||
onclick="openBrowserSession('${stream.id}', '${escapeAttr(stream.title)}', '${escapeAttr(stream.url)}')">
|
||||
<div class="card-body">
|
||||
<div class="card-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</div>
|
||||
<div class="card-title">${escapeHtml(stream.title)}</div>
|
||||
<div class="card-url">${escapeHtml(stream.url)}</div>
|
||||
</div>
|
||||
<div class="card-bar">
|
||||
<div class="card-actions">
|
||||
<a href="${escapeHtml(stream.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" class="icon-btn" title="Open original">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function _submitStreamCommon(urlId, titleId, successMsg, reloadFn) {
|
||||
const urlInput = document.getElementById(urlId);
|
||||
const titleInput = document.getElementById(titleId);
|
||||
const url = urlInput.value.trim();
|
||||
const title = titleInput.value.trim();
|
||||
|
||||
if (!url) {
|
||||
showToast('URL is required', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
showToast('Please enter a valid URL', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/streams', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, title })
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
showToast(err.error || 'Failed to add stream', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
urlInput.value = '';
|
||||
titleInput.value = '';
|
||||
showToast(successMsg, 'success');
|
||||
reloadFn();
|
||||
} catch (e) {
|
||||
showToast('Failed to add stream', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function addPublicStream() {
|
||||
await _submitStreamCommon('public-submit-url', 'public-submit-title', 'Stream added', loadPublicStreams);
|
||||
}
|
||||
|
||||
async function submitStream() {
|
||||
await _submitStreamCommon('submit-url', 'submit-title', 'Stream submitted for review', loadMyStreams);
|
||||
}
|
||||
|
||||
async function deleteStream(id, isAdmin) {
|
||||
const confirmed = await showConfirm('Delete this stream?');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/streams/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
showToast(err.error || 'Failed to delete', 'error');
|
||||
return;
|
||||
}
|
||||
showToast('Stream deleted', 'success');
|
||||
if (isAdmin) {
|
||||
loadAdminStreams();
|
||||
} else {
|
||||
loadMyStreams();
|
||||
}
|
||||
loadPublicStreams();
|
||||
} catch (e) {
|
||||
showToast('Failed to delete stream', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePublish(id) {
|
||||
try {
|
||||
const resp = await fetch(`/api/streams/${id}/publish`, { method: 'PUT' });
|
||||
if (!resp.ok) {
|
||||
showToast('Failed to toggle publish', 'error');
|
||||
return;
|
||||
}
|
||||
showToast('Stream updated', 'success');
|
||||
loadAdminStreams();
|
||||
loadPublicStreams();
|
||||
} catch (e) {
|
||||
showToast('Failed to toggle publish', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRedditLinks() {
|
||||
try {
|
||||
const resp = await fetch('/api/scraped/refresh', { method: 'POST' });
|
||||
if (!resp.ok) {
|
||||
showToast('Failed to trigger refresh', 'error');
|
||||
return;
|
||||
}
|
||||
showToast('Refreshing links from Reddit...', 'info');
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 15;
|
||||
const poll = setInterval(async () => {
|
||||
attempts++;
|
||||
await loadRedditLinks();
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(poll);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
showToast('Failed to trigger refresh', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerScrape() {
|
||||
try {
|
||||
await fetch('/api/admin/scrape', { method: 'POST' });
|
||||
showToast('Scrape triggered', 'success');
|
||||
} catch (e) {
|
||||
showToast('Failed to trigger scrape', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeRedditViewer() {
|
||||
const viewer = document.getElementById('reddit-viewer');
|
||||
if (!viewer) return;
|
||||
viewer.classList.add('hidden');
|
||||
const contentEl = viewer.querySelector('.reddit-viewer-content');
|
||||
contentEl.querySelectorAll(':scope > :not(#reddit-viewer-loader)').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
// --- Browser Session Viewer (Iframe Proxy + Native Player) ---
|
||||
|
||||
async function openBrowserSession(streamId, streamTitle, streamURL) {
|
||||
const viewer = document.getElementById('browser-viewer');
|
||||
const statusEl = viewer.querySelector('.browser-viewer-status');
|
||||
const contentEl = viewer.querySelector('.browser-viewer-content');
|
||||
const loader = document.getElementById('browser-viewer-loader');
|
||||
const urlText = document.getElementById('browser-url');
|
||||
const openOriginal = document.getElementById('browser-open-original');
|
||||
|
||||
statusEl.textContent = 'Loading...';
|
||||
statusEl.classList.remove('connected');
|
||||
loader.classList.remove('hidden');
|
||||
|
||||
if (urlText) urlText.textContent = streamURL;
|
||||
if (openOriginal) openOriginal.href = streamURL;
|
||||
|
||||
// Hide all tab content sections and show the viewer
|
||||
document.querySelectorAll('.tab-content').forEach(s => s.classList.remove('active'));
|
||||
viewer.classList.remove('hidden');
|
||||
viewer.classList.add('active');
|
||||
|
||||
// Remove any existing iframe or player
|
||||
contentEl.querySelectorAll('.browser-iframe').forEach(el => el.remove());
|
||||
contentEl.querySelectorAll('#clappr-player').forEach(el => el.remove());
|
||||
destroyNativePlayer();
|
||||
|
||||
// Fetch player config to determine stream type
|
||||
const config = await getPlayerConfig(streamId);
|
||||
|
||||
if (config.type === 'hls' || config.type === 'daddylive') {
|
||||
// Native player mode
|
||||
const playerDiv = document.createElement('div');
|
||||
playerDiv.id = 'clappr-player';
|
||||
contentEl.appendChild(playerDiv);
|
||||
|
||||
loader.classList.add('hidden');
|
||||
statusEl.textContent = 'Playing';
|
||||
statusEl.classList.add('connected');
|
||||
|
||||
if (config.type === 'daddylive') {
|
||||
createDaddyLivePlayer('#clappr-player', config);
|
||||
} else {
|
||||
createHLSPlayer('#clappr-player', config.hls_url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: iframe proxy mode
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(streamURL);
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Invalid URL';
|
||||
loader.classList.add('hidden');
|
||||
showToast('Invalid stream URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = parsed.origin;
|
||||
const pathAndSearch = parsed.pathname + parsed.search + parsed.hash;
|
||||
const b64Origin = btoa(origin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const proxyURL = '/proxy/' + b64Origin + pathAndSearch;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = proxyURL;
|
||||
iframe.className = 'browser-iframe';
|
||||
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation');
|
||||
iframe.setAttribute('allow', 'autoplay; encrypted-media; fullscreen');
|
||||
iframe.setAttribute('allowfullscreen', '');
|
||||
iframe.onload = function() {
|
||||
loader.classList.add('hidden');
|
||||
statusEl.textContent = 'Connected';
|
||||
statusEl.classList.add('connected');
|
||||
};
|
||||
contentEl.appendChild(iframe);
|
||||
}
|
||||
|
||||
function closeBrowserSession() {
|
||||
destroyNativePlayer();
|
||||
const viewer = document.getElementById('browser-viewer');
|
||||
viewer.classList.add('hidden');
|
||||
viewer.classList.remove('active');
|
||||
const contentEl = viewer.querySelector('.browser-viewer-content');
|
||||
contentEl.querySelectorAll('.browser-iframe').forEach(el => el.remove());
|
||||
contentEl.querySelectorAll('#clappr-player').forEach(el => el.remove());
|
||||
const statusEl = viewer.querySelector('.browser-viewer-status');
|
||||
statusEl.textContent = '';
|
||||
statusEl.classList.remove('connected');
|
||||
const urlText = document.getElementById('browser-url');
|
||||
if (urlText) urlText.textContent = '';
|
||||
|
||||
// Restore the previously active tab
|
||||
const activeTab = document.querySelector('.tab-btn.active');
|
||||
if (activeTab) {
|
||||
const tabName = activeTab.dataset.tab;
|
||||
const content = document.getElementById('content-' + tabName);
|
||||
if (content) content.classList.add('active');
|
||||
}
|
||||
}
|
||||
9
stacks/f1-stream/module/files/static/js/utils.js
Normal file
9
stacks/f1-stream/module/files/static/js/utils.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
return str.replace(/&/g, '&').replace(/'/g, ''').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue