-
-

-
-
-
Support
-
- IPTVnator stays open source
-
-
- IPTVnator was built as an open-source project and will remain one. If it helps you, your support keeps the
- project healthy and the motivation high for continued improvements, fixes, and long-term maintenance.
+
+
+
+
+
IPTVnator stays open source
+
+ MIT-licensed since 2020. If it saves you an evening, a star or a coffee keeps the updates coming.
-
-
- {supportLinks.map((item) => (
+
+
+ ))
+ }
-
-
diff --git a/apps/website/src/lib/detect-platform.ts b/apps/website/src/lib/detect-platform.ts
new file mode 100644
index 000000000..6af18dd73
--- /dev/null
+++ b/apps/website/src/lib/detect-platform.ts
@@ -0,0 +1,58 @@
+import type { DownloadPlatform } from './platforms';
+
+/**
+ * Best-effort guess of the visitor's desktop OS, used only to pre-select a
+ * download button. Runs in the browser; the server-rendered markup must
+ * already be correct for the "unknown" case, since the guess can be wrong and
+ * phones get `null`.
+ *
+ * Sources are consulted in order of trust: the user-agent string first (every
+ * browser sends one and it is what a visitor's spoofing or testing tools
+ * change), then the client-hints platform, then the deprecated
+ * `navigator.platform`. The first source that yields a verdict wins, so a
+ * source that is silent (an empty hint) falls through instead of vetoing.
+ *
+ * iPadOS asks for desktop sites with a Macintosh user agent and `MacIntel`
+ * platform, so a "mac" verdict is only trusted on a device without touch
+ * points — no Mac has a touch screen, every iPad reports several.
+ */
+export function detectPlatform(): DownloadPlatform | null {
+ if (typeof navigator === 'undefined') {
+ return null;
+ }
+ const hints = (navigator as Navigator & { userAgentData?: { platform?: string; mobile?: boolean } }).userAgentData;
+ if (hints?.mobile) {
+ return null;
+ }
+ const sources = [navigator.userAgent, hints?.platform, navigator.platform];
+ for (const source of sources) {
+ const verdict = classify(source ?? '');
+ if (verdict === 'macos' && navigator.maxTouchPoints > 0) {
+ return null;
+ }
+ if (verdict !== undefined) {
+ return verdict;
+ }
+ }
+ return null;
+}
+
+/** `null` means "a phone or tablet, offer nothing"; `undefined` means "no opinion". */
+function classify(source: string): DownloadPlatform | null | undefined {
+ if (!source) {
+ return undefined;
+ }
+ if (/android|iphone|ipad|ipod|mobile/i.test(source)) {
+ return null;
+ }
+ if (/mac/i.test(source)) {
+ return 'macos';
+ }
+ if (/win/i.test(source)) {
+ return 'windows';
+ }
+ if (/linux|x11|cros/i.test(source)) {
+ return 'linux';
+ }
+ return undefined;
+}
diff --git a/apps/website/src/lib/github-stats.ts b/apps/website/src/lib/github-stats.ts
new file mode 100644
index 000000000..9d337a958
--- /dev/null
+++ b/apps/website/src/lib/github-stats.ts
@@ -0,0 +1,100 @@
+import { GITHUB_REPO } from './downloads';
+
+/**
+ * Project numbers for the home page hero, resolved once per build from the
+ * GitHub API. The hero used to embed shields.io badges for these; rendering
+ * them as text keeps the page self-contained and lets the numbers sit in the
+ * page's own typography. Any number the API cannot supply (offline build,
+ * rate limit, `WEBSITE_SKIP_RELEASE_FETCH=1`) is `null` and simply not shown —
+ * a stale or invented figure would be worse than a missing one.
+ */
+export interface GitHubStats {
+ stars: number | null;
+ /** Sum of asset download counts over the newest 100 releases. */
+ downloads: number | null;
+}
+
+const API_ROOT = `https://api.github.com/repos/${GITHUB_REPO}`;
+const FETCH_TIMEOUT_MS = 8000;
+const EMPTY: GitHubStats = { stars: null, downloads: null };
+
+let statsPromise: Promise | undefined;
+
+/** Resolves once per build; every page shares the same answer. */
+export function getGitHubStats(): Promise {
+ statsPromise ??= resolveStats();
+ return statsPromise;
+}
+
+/** `7013` → `7.0k`, `1_134_000` → `1.1M`. */
+export function formatCompactCount(value: number): string {
+ if (value >= 1_000_000) {
+ return `${(value / 1_000_000).toFixed(1)}M`;
+ }
+ if (value >= 10_000) {
+ return `${Math.round(value / 1_000)}k`;
+ }
+ if (value >= 1_000) {
+ return `${(value / 1_000).toFixed(1)}k`;
+ }
+ return String(value);
+}
+
+async function resolveStats(): Promise {
+ if (process.env.WEBSITE_SKIP_RELEASE_FETCH === '1') {
+ return EMPTY;
+ }
+ const [repo, releases] = await Promise.all([fetchJson(API_ROOT), fetchJson(`${API_ROOT}/releases?per_page=100`)]);
+ return { stars: readStars(repo), downloads: readDownloads(releases) };
+}
+
+async function fetchJson(url: string): Promise {
+ try {
+ const headers: Record = {
+ Accept: 'application/vnd.github+json',
+ 'X-GitHub-Api-Version': '2022-11-28',
+ 'User-Agent': 'iptvnator-website-build',
+ };
+ const token = process.env.GITHUB_TOKEN;
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
+ if (!response.ok) {
+ console.warn(`[website] GitHub stats lookup failed (HTTP ${response.status}); the hero shows no counts.`);
+ return null;
+ }
+ return await response.json();
+ } catch (error) {
+ console.warn(`[website] GitHub stats lookup failed (${error instanceof Error ? error.message : String(error)}); the hero shows no counts.`);
+ return null;
+ }
+}
+
+function readStars(payload: unknown): number | null {
+ if (!payload || typeof payload !== 'object') {
+ return null;
+ }
+ const stars = (payload as Record).stargazers_count;
+ return typeof stars === 'number' && Number.isFinite(stars) ? stars : null;
+}
+
+function readDownloads(payload: unknown): number | null {
+ if (!Array.isArray(payload) || payload.length === 0) {
+ return null;
+ }
+ let total = 0;
+ for (const release of payload) {
+ const assets = release && typeof release === 'object' ? (release as Record).assets : null;
+ if (!Array.isArray(assets)) {
+ continue;
+ }
+ for (const asset of assets) {
+ const count = asset && typeof asset === 'object' ? (asset as Record).download_count : null;
+ if (typeof count === 'number' && Number.isFinite(count)) {
+ total += count;
+ }
+ }
+ }
+ return total > 0 ? total : null;
+}
diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro
index b0728414d..7c4e5ab07 100644
--- a/apps/website/src/pages/index.astro
+++ b/apps/website/src/pages/index.astro
@@ -9,8 +9,9 @@ import SupportSection from '../components/SupportSection.astro';
import FeaturedBlogPost from '../components/FeaturedBlogPost.astro';
import BlogPostCard from '../components/BlogPostCard.astro';
import { getLatestRelease } from '../lib/downloads';
+import { getGitHubStats } from '../lib/github-stats';
-const release = await getLatestRelease();
+const [release, stats] = await Promise.all([getLatestRelease(), getGitHubStats()]);
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const allPosts = (await getCollection('blog'))
@@ -63,25 +64,20 @@ const homeSchema = [
keywords={['IPTV player', 'M3U', 'M3U8', 'Xtream Codes', 'Stalker portal', 'EPG', 'XMLTV', 'open-source']}
jsonLd={homeSchema}
>
-
+
-
+
{
posts.length > 0 && (
-
-
-
+
-
- Blog
-
-
+
Latest updates
diff --git a/apps/website/src/styles/global.css b/apps/website/src/styles/global.css
index c8844aa60..2b22e4ad6 100644
--- a/apps/website/src/styles/global.css
+++ b/apps/website/src/styles/global.css
@@ -67,26 +67,4 @@
.card-panel:has(.card-link:hover) {
@apply border-surface-600 bg-surface-900/70;
}
-
- /* ── Carousel infinite marquee ── */
- .carousel-track {
- animation: marquee 40s linear infinite;
- }
-
- .carousel-track:hover {
- animation-play-state: paused;
- }
-
- @keyframes marquee {
- 0% { transform: translateX(0); }
- 100% { transform: translateX(-50%); }
- }
-
- @media (prefers-reduced-motion: reduce) {
- .carousel-track {
- animation: none;
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- }
- }
}
diff --git a/tools/testing/website-home-sections.test.mjs b/tools/testing/website-home-sections.test.mjs
new file mode 100644
index 000000000..85c95e5a4
--- /dev/null
+++ b/tools/testing/website-home-sections.test.mjs
@@ -0,0 +1,160 @@
+import { readFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { BASE_PATH as BASE, distRoot, launchBrowser, serveDist } from './website-browser-support.mjs';
+
+/**
+ * Home page sections rebuilt in the landing redesign: the hero, the feature
+ * bento, the download panel and the footer.
+ *
+ * The structural half reads the built HTML and checks that the server-rendered
+ * markup is already correct for a visitor whose OS cannot be guessed. The
+ * browser half loads the page under desktop and phone user agents and checks
+ * the client-side OS detection: the hero's primary button and the "Detected"
+ * row in the download panel follow the platform, phones get the generic
+ * markup, and the copy buttons write to the clipboard. Without a Chromium the
+ * browser half is skipped locally and fails in CI (see
+ * website-browser-support.mjs).
+ */
+
+const PLATFORMS = ['windows', 'macos', 'linux'];
+
+test('hero: generic download CTA, self-host link and project facts', async () => {
+ const html = await readFile(join(distRoot, 'index.html'), 'utf8');
+ const primary = html.match(/]*data-hero-primary[^>]*>[\s\S]*?<\/a>/)?.[0];
+ assert.ok(primary, 'hero primary button');
+ assert.match(primary, /href="\/iptvnator\/download\/"/, 'server-rendered CTA goes to the download hub');
+ assert.match(primary, /Download for desktop/);
+ assert.match(html, /data-hero-secondary[^>]*>\s*All platforms/);
+ assert.match(html, /href="\/iptvnator\/download\/docker\/"[^>]*>\s*Self-host the web app/);
+ assert.match(html, /]*>19<\/dd>/, 'languages fact');
+ assert.match(html, /]*>MIT<\/dd>/, 'license fact');
+ assert.doesNotMatch(html, /img\.shields\.io/, 'no badge images');
+ assert.doesNotMatch(html, /border-dashed/, 'no dashed borders anywhere on the home page');
+});
+
+test('features bento links every feature page and shows interface fragments', async () => {
+ const html = await readFile(join(distRoot, 'index.html'), 'utf8');
+ const section = html.match(//)?.[0];
+ assert.ok(section, 'features section');
+ for (const slug of ['m3u-player', 'xtream-codes-player', 'stalker-portal-player', 'epg', 'remote-control']) {
+ assert.match(section, new RegExp(`href="/iptvnator/features/${slug}/"`));
+ }
+ assert.match(section, /Aurora News/, 'EPG fragment uses fictional channels');
+ assert.match(section, /REC/, 'recording fragment');
+ assert.doesNotMatch(section, /carousel-track/, 'the marquee is gone');
+});
+
+test('download panel: one row per platform with release file names and package commands', async () => {
+ const html = await readFile(join(distRoot, 'index.html'), 'utf8');
+ const section = html.match(//)?.[0];
+ assert.ok(section, 'download section');
+ assert.match(section, /Get IPTVnator \d+\.\d+\.\d+/);
+ for (const platform of PLATFORMS) {
+ const row = section.match(new RegExp(`]*data-platform-row="${platform}"[\\s\\S]*?data-platform-link[^>]*>[\\s\\S]*?`))?.[0];
+ assert.ok(row, `${platform} row`);
+ assert.match(row, new RegExp(`href="/iptvnator/download/${platform}/"`));
+ assert.match(row, /iptvnator-\d+\.\d+\.\d+-/, 'lists a release file name');
+ assert.match(row, /class="hidden[^"]*"[^>]*data-detected-badge/, 'the Detected badge is hidden until the browser decides');
+ }
+ assert.match(section, /brew install --cask iptvnator/);
+ assert.match(section, /sudo snap install iptvnator/);
+ assert.match(section, /yay -S iptvnator-bin/);
+});
+
+test('footer carries the disclaimer, the mascot and the site links', async () => {
+ const html = await readFile(join(distRoot, 'index.html'), 'utf8');
+ const footer = html.match(/