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:
Viktor Barzin 2026-06-05 06:51:22 +00:00
parent 99f9bf8d89
commit e8bfb4d06b
51 changed files with 131 additions and 9556 deletions

View file

@ -1,3 +0,0 @@
node_modules/
build/
.svelte-kit/

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -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;
}

View file

@ -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>

View file

@ -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();
}

View file

@ -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);

View file

@ -1,3 +0,0 @@
export const prerender = true;
export const ssr = false;
export const trailingSlash = 'always';

View file

@ -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>

View file

@ -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} &middot; {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} &middot; {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'}
&middot; {countdown(session.start_utc)}
{/if}
</p>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -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} &middot; {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} &middot; {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} &middot; {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>

View file

@ -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;

View file

@ -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()
]
});