f1-stream: consume Forgejo-registry image; drop in-monorepo source
The actively-developed f1-stream (infra files/ copy: 12 active extractors +
Playwright/chrome-service verifier) is now its own repo viktor/f1-stream and is
the deployed app (replacing the stale March github build).
- main.tf: image -> forgejo.viktorbarzin.me/viktor/f1-stream:${var.image_tag}
+ image_pull_secrets registry-credentials. Image stays in KEEL_IGNORE_IMAGE.
- Remove stacks/f1-stream/files/ (source now in viktor/f1-stream).
- docs/plans: extraction design + plan pair.
Applied via tg + kubectl set image to forgejo:24857a82; live /health green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
99f9bf8d89
commit
e8bfb4d06b
51 changed files with 131 additions and 9556 deletions
3
stacks/f1-stream/files/frontend/.gitignore
vendored
3
stacks/f1-stream/files/frontend/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
2140
stacks/f1-stream/files/frontend/package-lock.json
generated
2140
stacks/f1-stream/files/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"name": "f1-stream-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-f1-red: #e10600;
|
||||
--color-f1-red-dark: #b50500;
|
||||
--color-f1-bg: #111111;
|
||||
--color-f1-surface: #1a1a1a;
|
||||
--color-f1-surface-hover: #242424;
|
||||
--color-f1-border: #2a2a2a;
|
||||
--color-f1-text: #e0e0e0;
|
||||
--color-f1-text-muted: #888888;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-f1-bg);
|
||||
color: var(--color-f1-text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-f1-bg);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-f1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* HLS video player */
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏎</text></svg>" />
|
||||
<title>F1 Stream</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
/**
|
||||
* API client for the F1 Streams backend.
|
||||
* All endpoints are on the same origin, so no CORS issues.
|
||||
*/
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
/**
|
||||
* Fetch the F1 race schedule with session statuses.
|
||||
* @returns {Promise<{season: string, fetched_at: string, races: Array}>}
|
||||
*/
|
||||
export async function fetchSchedule() {
|
||||
const res = await fetch(`${API_BASE}/schedule`);
|
||||
if (!res.ok) throw new Error(`Schedule fetch failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available live streams.
|
||||
* @returns {Promise<{streams: Array, count: number}>}
|
||||
*/
|
||||
export async function fetchStreams() {
|
||||
const res = await fetch(`${API_BASE}/streams`);
|
||||
if (!res.ok) throw new Error(`Streams fetch failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URL to base64url for the proxy endpoint.
|
||||
* @param {string} rawUrl - The original m3u8 URL
|
||||
* @returns {string} base64url-encoded string
|
||||
*/
|
||||
function toBase64Url(rawUrl) {
|
||||
return btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the proxied m3u8 URL for HLS playback.
|
||||
* @param {string} m3u8Url - The original m3u8 URL
|
||||
* @returns {string} The proxy URL
|
||||
*/
|
||||
export function getProxyUrl(m3u8Url) {
|
||||
const encoded = toBase64Url(m3u8Url);
|
||||
return `${API_BASE}/proxy?url=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the embed-proxy URL for an upstream iframe embed page.
|
||||
*
|
||||
* The proxy strips X-Frame-Options / CSP frame-ancestors and injects a
|
||||
* frame-buster-defeat script so the embed renders inside our iframe even
|
||||
* when the upstream tries to block it.
|
||||
* @param {string} embedUrl - The original embed page URL
|
||||
* @returns {string} URL pointing at our /embed proxy
|
||||
*/
|
||||
export function getEmbedProxyUrl(embedUrl) {
|
||||
const encoded = toBase64Url(embedUrl);
|
||||
return `${API_BASE}/embed?url=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stream as actively being watched (enables token refresh).
|
||||
* @param {string} url - The stream URL
|
||||
* @param {string} [siteKey] - Optional site key
|
||||
*/
|
||||
export async function activateStream(url, siteKey = '') {
|
||||
const res = await fetch(`${API_BASE}/streams/activate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, site_key: siteKey })
|
||||
});
|
||||
if (!res.ok) throw new Error(`Activate failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stream as no longer being watched.
|
||||
* @param {string} url - The stream URL
|
||||
*/
|
||||
export async function deactivateStream(url) {
|
||||
const res = await fetch(`${API_BASE}/streams/deactivate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
if (!res.ok) throw new Error(`Deactivate failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
/** Schedule data store */
|
||||
export const schedule = writable(null);
|
||||
|
||||
/** Streams data store */
|
||||
export const streams = writable(null);
|
||||
|
||||
/** Loading state */
|
||||
export const loading = writable(false);
|
||||
|
||||
/** Error state */
|
||||
export const error = writable(null);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export const prerender = true;
|
||||
export const ssr = false;
|
||||
export const trailingSlash = 'always';
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<script>
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="border-b border-f1-border bg-f1-surface">
|
||||
<nav class="max-w-6xl mx-auto px-4 py-3 flex items-center gap-6">
|
||||
<a href="/" class="flex items-center gap-2 text-lg font-bold text-white hover:text-f1-red transition-colors">
|
||||
<span class="text-f1-red font-black text-xl">F1</span>
|
||||
<span>Stream</span>
|
||||
</a>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="text-f1-text-muted hover:text-white transition-colors">Schedule</a>
|
||||
<a href="/watch" class="text-f1-text-muted hover:text-white transition-colors">Watch</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-f1-border py-3 text-center text-xs text-f1-text-muted">
|
||||
F1 Stream
|
||||
</footer>
|
||||
</div>
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
<script>
|
||||
import { fetchSchedule } from '$lib/api.js';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let scheduleData = $state(null);
|
||||
let loading = $state(true);
|
||||
let errorMsg = $state(null);
|
||||
let now = $state(new Date());
|
||||
|
||||
// Update "now" every 30 seconds for live countdown
|
||||
let timer;
|
||||
onMount(() => {
|
||||
loadSchedule();
|
||||
timer = setInterval(() => { now = new Date(); }, 30000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
async function loadSchedule() {
|
||||
loading = true;
|
||||
errorMsg = null;
|
||||
try {
|
||||
scheduleData = await fetchSchedule();
|
||||
} catch (e) {
|
||||
errorMsg = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next upcoming session across all races.
|
||||
*/
|
||||
let nextSession = $derived.by(() => {
|
||||
if (!scheduleData?.races) return null;
|
||||
for (const race of scheduleData.races) {
|
||||
for (const session of race.sessions) {
|
||||
if (session.status === 'upcoming') {
|
||||
return { race, session };
|
||||
}
|
||||
if (session.status === 'live') {
|
||||
return { race, session };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Format an ISO date string to the user's local timezone.
|
||||
*/
|
||||
function formatLocalTime(isoStr) {
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a short date (day + month).
|
||||
*/
|
||||
function formatShortDate(isoStr) {
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a time only.
|
||||
*/
|
||||
function formatTime(isoStr) {
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute countdown string to a future ISO date.
|
||||
*/
|
||||
function countdown(isoStr) {
|
||||
const target = new Date(isoStr);
|
||||
const diff = target - now;
|
||||
if (diff <= 0) return 'Now';
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h ${mins}m`;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge classes.
|
||||
*/
|
||||
function statusClasses(status) {
|
||||
switch (status) {
|
||||
case 'live': return 'bg-f1-red text-white';
|
||||
case 'upcoming': return 'bg-blue-600 text-white';
|
||||
case 'past': return 'bg-neutral-700 text-neutral-400';
|
||||
default: return 'bg-neutral-700 text-neutral-400';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a race has any live or upcoming sessions (to highlight it).
|
||||
*/
|
||||
function raceIsActive(race) {
|
||||
return race.sessions.some(s => s.status === 'live' || s.status === 'upcoming');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a race is entirely in the past.
|
||||
*/
|
||||
function raceIsPast(race) {
|
||||
return race.sessions.every(s => s.status === 'past');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>F1 Stream - Schedule</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 py-6">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<div class="w-8 h-8 border-2 border-f1-red border-t-transparent rounded-full animate-spin"></div>
|
||||
<span class="ml-3 text-f1-text-muted">Loading schedule...</span>
|
||||
</div>
|
||||
{:else if errorMsg}
|
||||
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-center">
|
||||
<p class="text-red-300">Failed to load schedule: {errorMsg}</p>
|
||||
<button onclick={loadSchedule} class="mt-2 px-4 py-1 bg-f1-red text-white rounded text-sm hover:bg-f1-red-dark transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if scheduleData}
|
||||
<!-- Next Session Countdown -->
|
||||
{#if nextSession}
|
||||
<div class="mb-8 bg-f1-surface border border-f1-border rounded-lg p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-f1-text-muted text-sm uppercase tracking-wider">
|
||||
{nextSession.session.status === 'live' ? 'Live Now' : 'Next Session'}
|
||||
</p>
|
||||
<h2 class="text-xl font-bold text-white mt-1">
|
||||
{nextSession.race.race_name} - {nextSession.session.name}
|
||||
</h2>
|
||||
<p class="text-f1-text-muted text-sm mt-1">
|
||||
{nextSession.race.circuit} · {nextSession.race.country}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if nextSession.session.status === 'live'}
|
||||
<a href="/watch" class="inline-flex items-center gap-2 px-5 py-2 bg-f1-red text-white font-semibold rounded-lg hover:bg-f1-red-dark transition-colors">
|
||||
<span class="w-2 h-2 rounded-full bg-white animate-pulse"></span>
|
||||
Watch Live
|
||||
</a>
|
||||
{:else}
|
||||
<p class="text-2xl font-mono font-bold text-white">{countdown(nextSession.session.start_utc)}</p>
|
||||
<p class="text-f1-text-muted text-sm">{formatLocalTime(nextSession.session.start_utc)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Season Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">{scheduleData.season} Season</h1>
|
||||
<span class="text-xs text-f1-text-muted">{scheduleData.races.length} races</span>
|
||||
</div>
|
||||
|
||||
<!-- Race List -->
|
||||
<div class="space-y-4">
|
||||
{#each scheduleData.races as race (race.round)}
|
||||
{@const isPast = raceIsPast(race)}
|
||||
<div class="bg-f1-surface border border-f1-border rounded-lg overflow-hidden {isPast ? 'opacity-50' : ''}">
|
||||
<!-- Race Header -->
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-f1-text-muted text-sm font-mono w-8">R{race.round}</span>
|
||||
<div>
|
||||
<h3 class="font-semibold text-white">{race.race_name}</h3>
|
||||
<p class="text-xs text-f1-text-muted">{race.circuit} · {race.locality}, {race.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-f1-text-muted">{formatShortDate(race.date)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Sessions -->
|
||||
<div class="border-t border-f1-border">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-px bg-f1-border">
|
||||
{#each race.sessions as session}
|
||||
{@const isLive = session.status === 'live'}
|
||||
{@const isClickable = isLive}
|
||||
<div class="bg-f1-surface px-3 py-2 {isLive ? 'bg-f1-red/10' : ''} {isClickable ? 'hover:bg-f1-surface-hover cursor-pointer' : ''}">
|
||||
{#if isClickable}
|
||||
<a href="/watch?session={session.type}&round={race.round}" class="block">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-white">{session.name}</span>
|
||||
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded {statusClasses(session.status)}">
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-f1-text-muted mt-0.5">{formatTime(session.start_utc)}</p>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium {session.status === 'past' ? 'text-f1-text-muted' : 'text-white'}">{session.name}</span>
|
||||
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded {statusClasses(session.status)}">
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-f1-text-muted mt-0.5">
|
||||
{formatTime(session.start_utc)}
|
||||
{#if session.status === 'upcoming'}
|
||||
· {countdown(session.start_utc)}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,484 +0,0 @@
|
|||
<script>
|
||||
import { fetchStreams, fetchSchedule, getProxyUrl, getEmbedProxyUrl, activateStream, deactivateStream } from '$lib/api.js';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
// Lazy-load hls.js to code-split it into a separate chunk
|
||||
let Hls = $state(null);
|
||||
|
||||
// Query params
|
||||
let sessionType = $derived(page.url?.searchParams?.get('session') || '');
|
||||
let roundNumber = $derived(page.url?.searchParams?.get('round') || '');
|
||||
|
||||
// State
|
||||
let streamsData = $state(null);
|
||||
let scheduleData = $state(null);
|
||||
let loading = $state(true);
|
||||
let errorMsg = $state(null);
|
||||
|
||||
// Multi-stream player state: array of active player slots
|
||||
let players = $state([]);
|
||||
const MAX_PLAYERS = 4;
|
||||
|
||||
// Current session info from schedule
|
||||
let currentRace = $derived.by(() => {
|
||||
if (!scheduleData?.races || !roundNumber) return null;
|
||||
return scheduleData.races.find(r => r.round === parseInt(roundNumber));
|
||||
});
|
||||
|
||||
let currentSession = $derived.by(() => {
|
||||
if (!currentRace || !sessionType) return null;
|
||||
return currentRace.sessions.find(s => s.type === sessionType);
|
||||
});
|
||||
|
||||
// Layout class based on player count
|
||||
let layoutClass = $derived.by(() => {
|
||||
const count = players.length;
|
||||
if (count <= 1) return 'grid-cols-1';
|
||||
if (count === 2) return 'grid-cols-2';
|
||||
return 'grid-cols-2'; // 3-4 players: 2x2 grid
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const hlsModule = await import('hls.js');
|
||||
Hls = hlsModule.default;
|
||||
loadData();
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Clean up all players
|
||||
for (const player of players) {
|
||||
cleanupPlayer(player);
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
errorMsg = null;
|
||||
try {
|
||||
const [streamsResult, scheduleResult] = await Promise.all([
|
||||
fetchStreams(),
|
||||
fetchSchedule()
|
||||
]);
|
||||
streamsData = streamsResult;
|
||||
scheduleData = scheduleResult;
|
||||
} catch (e) {
|
||||
errorMsg = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupPlayer(player) {
|
||||
if (player.hls) {
|
||||
player.hls.destroy();
|
||||
player.hls = null;
|
||||
}
|
||||
if (player.originalUrl) {
|
||||
deactivateStream(player.originalUrl).catch(() => {});
|
||||
}
|
||||
if (player.controlsTimer) {
|
||||
clearTimeout(player.controlsTimer);
|
||||
}
|
||||
}
|
||||
|
||||
function removePlayer(index) {
|
||||
const player = players[index];
|
||||
cleanupPlayer(player);
|
||||
players = players.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function isStreamActive(url) {
|
||||
return players.some(p => p.originalUrl === url);
|
||||
}
|
||||
|
||||
function playStream(stream) {
|
||||
// If already playing this stream, don't add a duplicate
|
||||
const streamUrl = stream.stream_type === 'embed' ? stream.embed_url : stream.url;
|
||||
if (isStreamActive(streamUrl)) return;
|
||||
|
||||
// If at max players, replace the last one
|
||||
if (players.length >= MAX_PLAYERS) {
|
||||
removePlayer(players.length - 1);
|
||||
}
|
||||
|
||||
if (stream.stream_type === 'embed') {
|
||||
// Embed/iframe player — route through our /embed proxy so the
|
||||
// upstream's X-Frame-Options / CSP / JS frame-busters can't
|
||||
// block the iframe.
|
||||
const newPlayer = {
|
||||
id: Date.now(),
|
||||
proxyUrl: '',
|
||||
originalUrl: stream.embed_url,
|
||||
embedUrl: getEmbedProxyUrl(stream.embed_url),
|
||||
streamType: 'embed',
|
||||
siteKey: stream.site_key || '',
|
||||
siteName: stream.site_name || stream.site_key || 'Unknown',
|
||||
quality: stream.quality || '',
|
||||
isPlaying: true,
|
||||
isMuted: false,
|
||||
volume: 1,
|
||||
showControls: true,
|
||||
error: null,
|
||||
videoEl: null,
|
||||
containerEl: null,
|
||||
hls: null,
|
||||
controlsTimer: null,
|
||||
};
|
||||
players = [...players, newPlayer];
|
||||
return;
|
||||
}
|
||||
|
||||
// m3u8 player — use hls.js
|
||||
if (!Hls) return;
|
||||
|
||||
const proxyUrl = getProxyUrl(stream.url);
|
||||
const newPlayer = {
|
||||
id: Date.now(),
|
||||
proxyUrl,
|
||||
originalUrl: stream.url,
|
||||
embedUrl: '',
|
||||
streamType: 'm3u8',
|
||||
siteKey: stream.site_key || '',
|
||||
siteName: stream.site_name || stream.site_key || 'Unknown',
|
||||
quality: stream.quality || '',
|
||||
isPlaying: false,
|
||||
isMuted: false,
|
||||
volume: 1,
|
||||
showControls: true,
|
||||
error: null,
|
||||
videoEl: null,
|
||||
containerEl: null,
|
||||
hls: null,
|
||||
controlsTimer: null,
|
||||
};
|
||||
|
||||
players = [...players, newPlayer];
|
||||
|
||||
// Activate stream for token refresh
|
||||
activateStream(stream.url, stream.site_key || '').catch(() => {});
|
||||
|
||||
// Wait for DOM to update then initialize player
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
initPlayer(players.length - 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initPlayer(index) {
|
||||
const player = players[index];
|
||||
if (!player || !player.videoEl) return;
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
// `lowLatencyMode` previously broke playback on regular (non-LL-HLS)
|
||||
// providers like RallyTV — they don't ship the LL-HLS extensions
|
||||
// hls.js needs in that mode. Default off; explicit per-stream flag
|
||||
// can re-enable later.
|
||||
const hlsInstance = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
backBufferLength: 90
|
||||
});
|
||||
|
||||
hlsInstance.loadSource(player.proxyUrl);
|
||||
hlsInstance.attachMedia(player.videoEl);
|
||||
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
player.videoEl.play().catch(() => {});
|
||||
players[index] = { ...player, isPlaying: true, hls: hlsInstance };
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.ERROR, (event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
players[index] = { ...players[index], error: `Network error: ${data.details}` };
|
||||
hlsInstance.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
players[index] = { ...players[index], error: `Media error: ${data.details}` };
|
||||
hlsInstance.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
players[index] = { ...players[index], error: `Fatal error: ${data.details}` };
|
||||
removePlayer(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
player.hls = hlsInstance;
|
||||
} else if (player.videoEl.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS (Safari)
|
||||
player.videoEl.src = player.proxyUrl;
|
||||
player.videoEl.addEventListener('loadedmetadata', () => {
|
||||
player.videoEl.play().catch(() => {});
|
||||
players[index] = { ...player, isPlaying: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay(index) {
|
||||
const player = players[index];
|
||||
if (!player?.videoEl) return;
|
||||
if (player.videoEl.paused) {
|
||||
player.videoEl.play().catch(() => {});
|
||||
players[index] = { ...player, isPlaying: true };
|
||||
} else {
|
||||
player.videoEl.pause();
|
||||
players[index] = { ...player, isPlaying: false };
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute(index) {
|
||||
const player = players[index];
|
||||
if (!player?.videoEl) return;
|
||||
const newMuted = !player.isMuted;
|
||||
player.videoEl.muted = newMuted;
|
||||
players[index] = { ...player, isMuted: newMuted };
|
||||
}
|
||||
|
||||
function setVolume(index, e) {
|
||||
const player = players[index];
|
||||
if (!player?.videoEl) return;
|
||||
const vol = parseFloat(e.target.value);
|
||||
player.videoEl.volume = vol;
|
||||
const muted = vol === 0;
|
||||
player.videoEl.muted = muted;
|
||||
players[index] = { ...player, volume: vol, isMuted: muted };
|
||||
}
|
||||
|
||||
function toggleFullscreen(index) {
|
||||
const player = players[index];
|
||||
if (!player?.containerEl) return;
|
||||
if (!document.fullscreenElement) {
|
||||
player.containerEl.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let isFullscreen = $state(false);
|
||||
function onFullscreenChange() {
|
||||
isFullscreen = !!document.fullscreenElement;
|
||||
}
|
||||
|
||||
function onPlayerMouseMove(index) {
|
||||
const player = players[index];
|
||||
if (!player) return;
|
||||
if (player.controlsTimer) clearTimeout(player.controlsTimer);
|
||||
players[index] = { ...player, showControls: true };
|
||||
const timer = setTimeout(() => {
|
||||
if (players[index]?.isPlaying) {
|
||||
players[index] = { ...players[index], showControls: false };
|
||||
}
|
||||
}, 3000);
|
||||
players[index] = { ...players[index], controlsTimer: timer };
|
||||
}
|
||||
|
||||
function responseTimeColor(ms) {
|
||||
if (ms < 500) return 'text-green-400';
|
||||
if (ms < 1500) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>F1 Stream - Watch{currentRace ? ` - ${currentRace.race_name}` : ''}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-6">
|
||||
<!-- Session Info Header -->
|
||||
{#if currentRace && currentSession}
|
||||
<div class="mb-6">
|
||||
<p class="text-f1-text-muted text-sm uppercase tracking-wider">
|
||||
Round {currentRace.round} · {currentSession.name}
|
||||
</p>
|
||||
<h1 class="text-2xl font-bold text-white">{currentRace.race_name}</h1>
|
||||
<p class="text-f1-text-muted text-sm">{currentRace.circuit} · {currentRace.country}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Watch</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Multi-Stream Players Grid -->
|
||||
{#if players.length > 0}
|
||||
<div class="grid {layoutClass} gap-2 mb-6">
|
||||
{#each players as player, i (player.id)}
|
||||
<div
|
||||
class="bg-black rounded-lg overflow-hidden relative group"
|
||||
bind:this={player.containerEl}
|
||||
onmousemove={() => onPlayerMouseMove(i)}
|
||||
role="region"
|
||||
aria-label="Video player {i + 1}"
|
||||
>
|
||||
<!-- Stream label -->
|
||||
<div class="absolute top-2 left-2 z-10 bg-black/60 rounded px-2 py-0.5 text-xs text-white">
|
||||
{player.siteName}{#if player.quality} · {player.quality}{/if}
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={() => removePlayer(i)}
|
||||
class="absolute top-2 right-2 z-10 bg-black/60 rounded-full w-6 h-6 flex items-center justify-center text-white hover:text-f1-red hover:bg-black/80 transition-colors"
|
||||
aria-label="Close stream"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- Video or Iframe -->
|
||||
{#if player.streamType === 'embed'}
|
||||
<iframe
|
||||
src={player.embedUrl}
|
||||
class="w-full aspect-video bg-black"
|
||||
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||
allowfullscreen
|
||||
frameborder="0"
|
||||
title="{player.siteName} stream"
|
||||
></iframe>
|
||||
{:else}
|
||||
<video
|
||||
bind:this={player.videoEl}
|
||||
class="w-full aspect-video bg-black"
|
||||
playsinline
|
||||
></video>
|
||||
{/if}
|
||||
|
||||
<!-- Controls Overlay -->
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2 transition-opacity duration-300 {player.showControls ? 'opacity-100' : 'opacity-0'}">
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick={() => togglePlay(i)} class="text-white hover:text-f1-red transition-colors" aria-label={player.isPlaying ? 'Pause' : 'Play'}>
|
||||
{#if player.isPlaying}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button onclick={() => toggleMute(i)} class="text-white hover:text-f1-red transition-colors" aria-label={player.isMuted ? 'Unmute' : 'Mute'}>
|
||||
{#if player.isMuted || player.volume === 0}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
value={player.volume}
|
||||
oninput={(e) => setVolume(i, e)}
|
||||
class="w-16 h-1 accent-f1-red"
|
||||
aria-label="Volume"
|
||||
/>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<button onclick={() => toggleFullscreen(i)} class="text-white hover:text-f1-red transition-colors" aria-label="Fullscreen">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error overlay -->
|
||||
{#if player.error}
|
||||
<div class="absolute bottom-12 left-2 right-2 bg-red-900/80 rounded px-2 py-1 text-xs text-red-300">
|
||||
{player.error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stream List -->
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<div class="w-8 h-8 border-2 border-f1-red border-t-transparent rounded-full animate-spin"></div>
|
||||
<span class="ml-3 text-f1-text-muted">Loading streams...</span>
|
||||
</div>
|
||||
{:else if errorMsg}
|
||||
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-center">
|
||||
<p class="text-red-300">Failed to load streams: {errorMsg}</p>
|
||||
<button onclick={loadData} class="mt-2 px-4 py-1 bg-f1-red text-white rounded text-sm hover:bg-f1-red-dark transition-colors">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else if streamsData}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">
|
||||
Available Streams
|
||||
<span class="text-f1-text-muted font-normal text-sm ml-2">({streamsData.count})</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if players.length > 0}
|
||||
<span class="text-xs text-f1-text-muted">{players.length}/{MAX_PLAYERS} streams active</span>
|
||||
{/if}
|
||||
<button onclick={loadData} class="text-xs text-f1-text-muted hover:text-white transition-colors uppercase tracking-wider">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if streamsData.streams.length === 0}
|
||||
<div class="bg-f1-surface border border-f1-border rounded-lg p-8 text-center">
|
||||
<p class="text-f1-text-muted">No streams available right now.</p>
|
||||
<p class="text-f1-text-muted text-sm mt-2">Streams appear when a session is live. Check the schedule for upcoming sessions.</p>
|
||||
<a href="/" class="inline-block mt-4 px-4 py-2 bg-f1-surface-hover border border-f1-border rounded text-sm text-white hover:border-f1-red transition-colors">
|
||||
View Schedule
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each streamsData.streams as stream, i}
|
||||
{@const active = isStreamActive(stream.stream_type === 'embed' ? stream.embed_url : stream.url)}
|
||||
<div class="bg-f1-surface border rounded-lg px-4 py-3 flex items-center gap-4 {active ? 'border-f1-red' : 'border-f1-border hover:border-f1-border'}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white truncate">{stream.site_name || stream.site_key || 'Unknown'}</span>
|
||||
{#if stream.is_live}
|
||||
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-f1-red text-white">Live</span>
|
||||
{/if}
|
||||
{#if stream.stream_type === 'embed'}
|
||||
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-blue-600 text-white">Embed</span>
|
||||
{/if}
|
||||
{#if active}
|
||||
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-green-600 text-white">Playing</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-f1-text-muted">
|
||||
{#if stream.title}
|
||||
<span class="truncate">{stream.title}</span>
|
||||
{/if}
|
||||
{#if stream.quality}
|
||||
<span>{stream.quality}</span>
|
||||
{/if}
|
||||
{#if stream.response_time_ms != null}
|
||||
<span class={responseTimeColor(stream.response_time_ms)}>
|
||||
{stream.response_time_ms}ms
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !active}
|
||||
<button
|
||||
onclick={() => playStream(stream)}
|
||||
class="px-4 py-1.5 rounded text-sm font-medium bg-f1-red text-white hover:bg-f1-red-dark transition-colors"
|
||||
>
|
||||
{players.length > 0 ? 'Add' : 'Watch'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="text-xs text-green-400">Active</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import adapter from '@sveltejs/adapter-static';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
pages: 'build',
|
||||
assets: 'build',
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
}),
|
||||
paths: {
|
||||
base: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit()
|
||||
]
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue