mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(website): rebuild the hero, features, download and support sections (#1551)
* feat(website): rebuild the hero, features, download and support sections Third landing redesign PR: the home page now speaks the app's own language instead of a template's. - Hero: left-aligned headline, a primary button that follows the visitor's OS (server-rendered fallback goes to /download/), an "All platforms" button and a text link for self-hosting. Project numbers (stars, downloads, languages, license) are set in the page's own typography from the GitHub API at build time (`lib/github-stats.ts`); anything the build cannot resolve is left out rather than faked. A small "Now playing" card with fictional demo content replaces the mascot, badges and social chips; the screenshot is lit by a blurred copy of itself, like the player's ambient mode. - Features: a bento of interface fragments (EPG rows with progress, posters with a resume bar, a download queue with a REC dot, a remote D-pad) instead of numbered cards with icon watermarks and a marquee. The marquee CSS is gone. - Download: one row per platform with the release's file names and a "Detected" badge for the visitor's OS (`lib/detect-platform.ts`), package-manager commands in the same panel. - Support becomes a single row; the disclaimer moves into the footer, which also gets the mascot and a link list. - Home sections drop the decorative eyebrows and the divider rules; the unused broadcast-wave, support-signal and watermark illustrations are removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(website): cover the rebuilt home sections and the OS-aware download CTA `website-home-sections.test.mjs` (in the website test target) reads the built home page — generic hero CTA to /download/, project facts, no badge images or dashed borders, every feature page linked from the bento, one download row per platform with release file names and the Detected badge hidden, the disclaimer and mascot in the footer — and then drives it in Chromium under Windows, macOS, Linux and Android user agents: the primary button and the Detected row follow the platform, the phone keeps the generic markup, and the copy button writes the command to the clipboard. `detectPlatform()` now consults the user-agent string before the client-hints platform and the deprecated `navigator.platform`, so the source a visitor's tools actually change decides first; silent sources fall through instead of vetoing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(website): keep iPads on the generic download path iPadOS asks for desktop sites with a Macintosh user agent and a `MacIntel` platform, so the OS guess sent iPad visitors to the macOS installers. A "mac" verdict is now trusted only on a device without touch points: no Mac has a touch screen, every iPad reports several. The browser test adds an iPad-in-desktop-mode row to the generic-device matrix next to the Android phone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9bcdbc0efb
commit
baba0529ef
16 files changed
+872
-822
No files matched your search
@@ -64,9 +64,11 @@ resolved version.
|
||||
canonicals, direct asset links, JSON-LD, cross-links and sitemap entries
|
||||
without depending on a specific version.
|
||||
|
||||
`tools/testing/website-screenshot-showcase.test.mjs` drives the built site in
|
||||
a real browser: the home page channel switcher (autoplay, hover/focus pausing,
|
||||
keyboard navigation, deferred frame sources). It relies on
|
||||
Two of the suites drive the built site in a real browser:
|
||||
`tools/testing/website-screenshot-showcase.test.mjs` (the home page channel
|
||||
switcher: autoplay, hover/focus pausing, keyboard navigation, deferred frame
|
||||
sources) and `tools/testing/website-home-sections.test.mjs` (the hero and
|
||||
download panel following the visitor's OS, the copy buttons). They share
|
||||
`tools/testing/website-browser-support.mjs`, which serves `dist/apps/website`
|
||||
on a loopback port and launches Chromium from the Playwright download or,
|
||||
failing that, the system Chrome/Chromium channel. Without any Chromium the
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs tools/testing/website-blog-tags.test.mjs tools/testing/website-screenshot-showcase.test.mjs"
|
||||
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs tools/testing/website-blog-tags.test.mjs tools/testing/website-screenshot-showcase.test.mjs tools/testing/website-home-sections.test.mjs"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="800" viewBox="0 0 1920 800" role="img" aria-label="Ambient broadcast waves background motif">
|
||||
<defs>
|
||||
<!-- Core palette -->
|
||||
<linearGradient id="tealFade" x1="1400" y1="80" x2="780" y2="720" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#20a8a8" stop-opacity="0.22"/>
|
||||
<stop offset="0.55" stop-color="#20a8a8" stop-opacity="0.07"/>
|
||||
<stop offset="1" stop-color="#20a8a8" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
|
||||
<radialGradient id="ringFade" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(1535 400) rotate(90) scale(420 740)">
|
||||
<stop offset="0" stop-color="#20a8a8" stop-opacity="0.10"/>
|
||||
<stop offset="0.6" stop-color="#20a8a8" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#20a8a8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="scanFade" x1="0" y1="0" x2="0" y2="800" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#c7ced2" stop-opacity="0.00"/>
|
||||
<stop offset="0.45" stop-color="#c7ced2" stop-opacity="0.06"/>
|
||||
<stop offset="1" stop-color="#c7ced2" stop-opacity="0.00"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Subtle glow (kept restrained for 5–20% overall opacity usage) -->
|
||||
<filter id="softGlow" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 0.55 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<filter id="microGlow" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="1.1" result="b"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="b"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Transparent background: no rect fill -->
|
||||
|
||||
<!-- Layer: scanline bands (subtle, keeps text readability) -->
|
||||
<g id="layer-scanlines" opacity="0.55">
|
||||
<!-- Wide soft bands -->
|
||||
<path d="M0 158H1920v38H0z" fill="url(#scanFade)" opacity="0.22"/>
|
||||
<path d="M0 298H1920v26H0z" fill="url(#scanFade)" opacity="0.16"/>
|
||||
<path d="M0 460H1920v30H0z" fill="url(#scanFade)" opacity="0.14"/>
|
||||
<path d="M0 610H1920v42H0z" fill="url(#scanFade)" opacity="0.18"/>
|
||||
|
||||
<!-- Thin scanline-inspired hairlines -->
|
||||
<g opacity="0.16" fill="#c7ced2">
|
||||
<rect x="0" y="214" width="1920" height="1"/>
|
||||
<rect x="0" y="238" width="1920" height="1"/>
|
||||
<rect x="0" y="512" width="1920" height="1"/>
|
||||
<rect x="0" y="536" width="1920" height="1"/>
|
||||
<rect x="0" y="684" width="1920" height="1"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Layer: broadcast wave arcs (asymmetric weight on right) -->
|
||||
<g id="layer-waves" filter="url(#softGlow)">
|
||||
<g fill="none" stroke="url(#tealFade)" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Primary sweeping arcs -->
|
||||
<path d="M1040 120
|
||||
C1320 90, 1560 150, 1750 280
|
||||
C1900 382, 1940 520, 1815 640
|
||||
C1690 760, 1450 780, 1240 710"
|
||||
stroke-width="2.2" opacity="0.62"/>
|
||||
<path d="M980 170
|
||||
C1250 130, 1500 190, 1700 318
|
||||
C1860 420, 1895 540, 1782 642
|
||||
C1668 745, 1450 760, 1260 704"
|
||||
stroke-width="1.6" opacity="0.48"/>
|
||||
<path d="M1140 70
|
||||
C1440 35, 1720 120, 1890 290
|
||||
C2020 420, 1990 585, 1840 690
|
||||
C1690 796, 1405 805, 1185 710"
|
||||
stroke-width="1.2" opacity="0.34"/>
|
||||
|
||||
<!-- Secondary inner arcs for layered depth -->
|
||||
<path d="M1245 235
|
||||
C1440 205, 1605 255, 1730 350
|
||||
C1830 426, 1850 520, 1770 595
|
||||
C1690 670, 1520 682, 1375 630"
|
||||
stroke-width="1.4" opacity="0.42"/>
|
||||
<path d="M1315 300
|
||||
C1490 280, 1625 330, 1715 402
|
||||
C1795 466, 1800 532, 1735 585
|
||||
C1670 638, 1535 640, 1425 604"
|
||||
stroke-width="1.1" opacity="0.30"/>
|
||||
</g>
|
||||
|
||||
<!-- Low-contrast charcoal support curve (adds CRT geometry without stealing focus) -->
|
||||
<path id="support-curve"
|
||||
d="M720 690
|
||||
C980 610, 1180 560, 1400 520
|
||||
C1560 492, 1730 480, 1920 490"
|
||||
fill="none" stroke="#0c1114" stroke-opacity="0.35" stroke-width="2" stroke-linecap="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Layer: radial rings (right side, very restrained) -->
|
||||
<g id="layer-rings" opacity="0.9">
|
||||
<g fill="none" stroke="url(#ringFade)" stroke-width="1.2" filter="url(#microGlow)">
|
||||
<circle cx="1535" cy="400" r="118" opacity="0.36"/>
|
||||
<circle cx="1535" cy="400" r="178" opacity="0.26"/>
|
||||
<circle cx="1535" cy="400" r="248" opacity="0.18"/>
|
||||
<circle cx="1535" cy="400" r="320" opacity="0.12"/>
|
||||
</g>
|
||||
|
||||
<!-- Offset subtle ring cluster for asymmetry -->
|
||||
<g fill="none" stroke="#20a8a8" stroke-opacity="0.10" stroke-width="1" filter="url(#microGlow)">
|
||||
<ellipse cx="1705" cy="290" rx="92" ry="62" opacity="0.35"/>
|
||||
<ellipse cx="1705" cy="290" rx="140" ry="96" opacity="0.22"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Layer: nodes & dots (kept sparse) -->
|
||||
<g id="layer-nodes" filter="url(#microGlow)">
|
||||
<!-- Main node cluster -->
|
||||
<g>
|
||||
<circle cx="1688" cy="402" r="3.2" fill="#20a8a8" fill-opacity="0.38"/>
|
||||
<circle cx="1622" cy="520" r="2.6" fill="#20a8a8" fill-opacity="0.28"/>
|
||||
<circle cx="1748" cy="312" r="2.4" fill="#20a8a8" fill-opacity="0.24"/>
|
||||
<circle cx="1586" cy="350" r="1.9" fill="#c7ced2" fill-opacity="0.18"/>
|
||||
<circle cx="1765" cy="520" r="1.8" fill="#c7ced2" fill-opacity="0.14"/>
|
||||
</g>
|
||||
|
||||
<!-- A few distant dots to balance without clutter -->
|
||||
<g opacity="0.55">
|
||||
<circle cx="1140" cy="250" r="1.6" fill="#20a8a8" fill-opacity="0.16"/>
|
||||
<circle cx="980" cy="560" r="1.5" fill="#c7ced2" fill-opacity="0.10"/>
|
||||
<circle cx="860" cy="340" r="1.4" fill="#20a8a8" fill-opacity="0.12"/>
|
||||
</g>
|
||||
|
||||
<!-- Thin connector hints (barely there) -->
|
||||
<g fill="none" stroke="#20a8a8" stroke-opacity="0.10" stroke-width="1" stroke-linecap="round">
|
||||
<path d="M1688 402 L1622 520" opacity="0.35"/>
|
||||
<path d="M1688 402 L1748 312" opacity="0.25"/>
|
||||
<path d="M1622 520 L1765 520" opacity="0.18"/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Layer: left-side negative-space soft vignette (improves text readability) -->
|
||||
<g id="layer-negative-space">
|
||||
<linearGradient id="leftVignette" x1="0" y1="0" x2="700" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#0b0f12" stop-opacity="0.26"/>
|
||||
<stop offset="0.75" stop-color="#0b0f12" stop-opacity="0.10"/>
|
||||
<stop offset="1" stop-color="#0b0f12" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<rect x="0" y="0" width="760" height="800" fill="url(#leftVignette)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
@@ -1,56 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>support-open-source-signal-isometric</title>
|
||||
<desc>Isometric architectural core representing the structural support of open-source software.</desc>
|
||||
|
||||
<!-- Central Isometric Core (The Engine) -->
|
||||
<g id="isometric-core" transform="translate(400, 420)">
|
||||
<!-- Bottom Plate -->
|
||||
<path d="M-120 0 L0 60 L120 0 L0 -60 Z" fill="#1a1f21" stroke="#3c474a" stroke-width="2" />
|
||||
|
||||
<!-- Vertical Pillars (Structure) -->
|
||||
<g stroke="#3c474a" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M-80 -20 V-100" />
|
||||
<path d="M80 -20 V-100" />
|
||||
<path d="M0 20 V-60" />
|
||||
</g>
|
||||
|
||||
<!-- The Floating Heart-Core (The Pulse) -->
|
||||
<g id="core-pulse" transform="translate(0, -90)">
|
||||
<!-- Isometric Cube/Diamond Representation -->
|
||||
<path d="M-40 0 L0 20 L40 0 L0 -20 Z" fill="#20a8a8" fill-opacity="0.2" stroke="#20a8a8" stroke-width="3">
|
||||
<animate attributeName="fill-opacity" values="0.2;0.5;0.2" dur="3s" repeatCount="indefinite" />
|
||||
</path>
|
||||
<path d="M-40 0 V30 L0 50 V20 Z" fill="#20a8a8" fill-opacity="0.1" stroke="#20a8a8" stroke-width="2" />
|
||||
<path d="M40 0 V30 L0 50 V20 Z" fill="#20a8a8" fill-opacity="0.05" stroke="#20a8a8" stroke-width="2" />
|
||||
|
||||
<!-- Internal Heart Motif (Simplified for Isometric) -->
|
||||
<path d="M-10 -5 Q0 -15 10 -5 L0 10 Z" fill="#4fd1d1" opacity="0.8">
|
||||
<animate attributeName="opacity" values="0.8;0.3;0.8" dur="1.5s" repeatCount="indefinite" />
|
||||
</path>
|
||||
</g>
|
||||
|
||||
<!-- Converging "Support" Streams -->
|
||||
<g id="data-streams" stroke="#20a8a8" stroke-width="1.5" opacity="0.4">
|
||||
<path d="M-200 -100 L-100 -50" stroke-dasharray="4 8" />
|
||||
<path d="M200 -100 L100 -50" stroke-dasharray="4 8" />
|
||||
<path d="M-150 100 L-80 50" />
|
||||
<path d="M150 100 L80 50" />
|
||||
|
||||
<!-- Stream End Nodes -->
|
||||
<circle cx="-100" cy="-50" r="3" fill="#20a8a8" />
|
||||
<circle cx="100" cy="-50" r="3" fill="#20a8a8" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Framing Elements -->
|
||||
<g id="frames" stroke="#3c474a" stroke-width="1" opacity="0.2">
|
||||
<circle cx="400" cy="400" r="280" />
|
||||
<path d="M200 200 L150 150 M600 200 L650 150 M200 600 L150 650 M600 600 L650 650" />
|
||||
</g>
|
||||
|
||||
<!-- Accent Highlights -->
|
||||
<circle cx="400" cy="330" r="4" fill="#4fd1d1" opacity="0.6">
|
||||
<animate attributeName="r" values="4;6;4" dur="3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 266 KiB |
@@ -1,113 +1,141 @@
|
||||
---
|
||||
import { PLATFORM_ICON_PATHS, PLATFORM_LABELS, PLATFORM_PAGES } from '../lib/platforms';
|
||||
import type { DownloadPlatform } from '../lib/platforms';
|
||||
import type { ResolvedRelease } from '../lib/downloads';
|
||||
import { formatReleaseDate, resolveDownloads } from '../lib/downloads';
|
||||
import { DOWNLOAD_PLATFORMS, PLATFORM_ICON_PATHS, PLATFORM_LABELS, PLATFORM_PAGES } from '../lib/platforms';
|
||||
|
||||
const platformOrder: DownloadPlatform[] = ['macos', 'windows', 'linux'];
|
||||
const platforms = platformOrder.map((platform) => ({
|
||||
name: PLATFORM_LABELS[platform],
|
||||
icon: PLATFORM_ICON_PATHS[platform],
|
||||
href: PLATFORM_PAGES[platform],
|
||||
}));
|
||||
interface Props {
|
||||
release: ResolvedRelease;
|
||||
}
|
||||
|
||||
const { release } = Astro.props;
|
||||
const releaseDate = formatReleaseDate(release.publishedAt);
|
||||
|
||||
/**
|
||||
* One row per platform, like the settings pages inside the app. The row lists
|
||||
* the files the release actually carries and sends the visitor to the
|
||||
* platform page, which has the direct links plus install and first-run notes.
|
||||
*/
|
||||
const rows = DOWNLOAD_PLATFORMS.map((platform) => {
|
||||
const downloads = resolveDownloads(release, platform);
|
||||
const shown = [...downloads.filter((d) => d.recommended), ...downloads.filter((d) => !d.recommended)].slice(0, 2);
|
||||
return {
|
||||
platform,
|
||||
label: PLATFORM_LABELS[platform],
|
||||
icon: PLATFORM_ICON_PATHS[platform],
|
||||
href: PLATFORM_PAGES[platform],
|
||||
files: shown.map((d) => ({ name: d.fileName, detail: d.detail })),
|
||||
more: Math.max(downloads.length - shown.length, 0),
|
||||
};
|
||||
});
|
||||
|
||||
const packageManagers = [
|
||||
{ label: 'Homebrew', command: 'brew install --cask iptvnator' },
|
||||
{ label: 'Snap', command: 'sudo snap install iptvnator' },
|
||||
{ label: 'AUR', command: 'yay -S iptvnator-bin' },
|
||||
];
|
||||
---
|
||||
|
||||
<section id="download" class="relative overflow-hidden py-28">
|
||||
<hr class="section-divider mb-28" />
|
||||
|
||||
<!-- Ambient glow -->
|
||||
<div class="pointer-events-none absolute bottom-0 left-1/2 h-[400px] w-[600px] -translate-x-1/2 rounded-full bg-accent-500/[0.04] blur-[100px]"></div>
|
||||
|
||||
<section id="download" class="relative py-24 sm:py-28">
|
||||
<div class="relative mx-auto max-w-6xl px-6">
|
||||
<!-- Centered editorial header -->
|
||||
<div class="mx-auto max-w-lg text-center">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
|
||||
Get started
|
||||
</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
Get IPTVnator
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h2 class="font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
Get IPTVnator {release.version}
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-surface-400">
|
||||
Download for your platform or use it directly in the browser.
|
||||
<p class="text-base text-surface-400 sm:text-right">
|
||||
{releaseDate && <span>Released {releaseDate} · </span>}
|
||||
<a href={release.url} class="text-accent-400 transition-colors hover:text-accent-300" rel="noopener">Release notes</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform buttons -->
|
||||
<div class="mt-14 flex flex-wrap justify-center gap-4">
|
||||
{
|
||||
platforms.map((platform) => (
|
||||
<a
|
||||
href={platform.href}
|
||||
class="card-panel group flex items-center gap-4 px-8 py-5"
|
||||
>
|
||||
<svg class="h-7 w-7 text-surface-500 transition-colors duration-200 group-hover:text-accent-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={platform.icon} />
|
||||
</svg>
|
||||
<div class="text-left">
|
||||
<div class="font-mono text-[10px] uppercase tracking-[0.15em] text-surface-500">Download for</div>
|
||||
<div class="mt-0.5 font-semibold text-surface-100">{platform.name}</div>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Package managers -->
|
||||
<div class="mx-auto mt-10 max-w-xl space-y-3">
|
||||
{
|
||||
packageManagers.map((pm) => (
|
||||
<div class="flex items-center gap-4 rounded-lg border border-surface-700/60 bg-surface-900/50 px-5 py-3.5">
|
||||
<span class="w-20 shrink-0 font-mono text-[10px] font-medium uppercase tracking-[0.15em] text-surface-500">
|
||||
{pm.label}
|
||||
</span>
|
||||
<code class="flex-1 font-mono text-sm text-accent-400/80">
|
||||
{pm.command}
|
||||
</code>
|
||||
<button
|
||||
class="copy-btn shrink-0 rounded-md p-1.5 text-surface-600 transition-colors hover:text-surface-300"
|
||||
data-command={pm.command}
|
||||
aria-label={`Copy ${pm.label} command`}
|
||||
<div class="mt-12 grid gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]" data-download-panel>
|
||||
<!-- Platform rows -->
|
||||
<div class="overflow-hidden rounded-xl border border-surface-800 bg-surface-900/40">
|
||||
{
|
||||
rows.map((row) => (
|
||||
<div
|
||||
class="grid gap-4 border-t border-surface-800 p-5 first:border-t-0 transition-colors duration-300 sm:grid-cols-[9.5rem_minmax(0,1fr)_auto] sm:items-center"
|
||||
data-platform-row={row.platform}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 font-display text-base font-semibold text-surface-50">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 text-surface-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={row.icon} />
|
||||
</svg>
|
||||
{row.label}
|
||||
<span class="hidden font-mono text-[10px] font-medium uppercase tracking-[0.1em] text-accent-400" data-detected-badge>
|
||||
Detected
|
||||
</span>
|
||||
</div>
|
||||
<ul class="min-w-0 font-mono text-xs leading-relaxed text-surface-400">
|
||||
{row.files.map((file) => (
|
||||
<li class="truncate" title={file.detail}>
|
||||
{file.name}
|
||||
</li>
|
||||
))}
|
||||
{row.more > 0 && <li class="text-surface-600">+ {row.more} more on the {row.label} page</li>}
|
||||
</ul>
|
||||
<a
|
||||
href={row.href}
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-lg border border-surface-700 px-4 py-2.5 text-sm font-semibold text-surface-100 transition-colors duration-200 hover:border-surface-500 hover:bg-surface-900"
|
||||
data-platform-link
|
||||
>
|
||||
Download for {row.label}
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Self-hosted web app link -->
|
||||
<div class="mt-10 text-center">
|
||||
<p class="text-sm text-surface-500">
|
||||
Prefer the browser version?{' '}
|
||||
<a
|
||||
href="/iptvnator/download/docker/"
|
||||
class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 transition-colors hover:text-accent-300 hover:decoration-accent-400/50"
|
||||
>
|
||||
Self-host it with Docker
|
||||
</a>
|
||||
</p>
|
||||
<!-- Package managers -->
|
||||
<div class="flex flex-col rounded-xl border border-surface-800 bg-surface-900/40">
|
||||
{
|
||||
packageManagers.map((pm) => (
|
||||
<div class="grid grid-cols-[4.5rem_minmax(0,1fr)_auto] items-center gap-3 border-t border-surface-800 px-5 py-4 first:border-t-0">
|
||||
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-surface-500">{pm.label}</span>
|
||||
<code class="min-w-0 font-mono text-[13px] text-surface-100">{pm.command}</code>
|
||||
<button
|
||||
type="button"
|
||||
class="copy-btn rounded-md border border-surface-700 px-2.5 py-1 font-mono text-[11px] text-surface-400 transition-colors hover:border-surface-500 hover:text-surface-100"
|
||||
data-command={pm.command}
|
||||
aria-label={`Copy ${pm.label} command`}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<p class="mt-auto border-t border-surface-800 px-5 py-4 text-sm leading-relaxed text-surface-400">
|
||||
Prefer the browser? <a href="/iptvnator/download/docker/" class="text-accent-400 transition-colors hover:text-accent-300">Self-host the web app</a> and open it as a PWA on any device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.copy-btn').forEach((btn) => {
|
||||
import { detectPlatform } from '../lib/detect-platform';
|
||||
|
||||
const platform = detectPlatform();
|
||||
if (platform) {
|
||||
const row = document.querySelector<HTMLElement>(`[data-platform-row="${platform}"]`);
|
||||
row?.classList.add('bg-surface-850/60');
|
||||
row?.querySelector('[data-detected-badge]')?.classList.remove('hidden');
|
||||
const link = row?.querySelector('[data-platform-link]');
|
||||
link?.classList.remove('border-surface-700', 'text-surface-100', 'hover:border-surface-500', 'hover:bg-surface-900');
|
||||
link?.classList.add('border-transparent', 'bg-accent-500', 'text-surface-950', 'hover:bg-accent-400');
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>('.copy-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const command = (btn as HTMLElement).dataset.command;
|
||||
if (command) {
|
||||
const command = btn.dataset.command;
|
||||
if (!command) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
const svg = btn.querySelector('svg');
|
||||
if (svg) {
|
||||
svg.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />';
|
||||
setTimeout(() => {
|
||||
svg.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />';
|
||||
}, 1500);
|
||||
}
|
||||
btn.textContent = 'Copied';
|
||||
window.setTimeout(() => {
|
||||
btn.textContent = 'Copy';
|
||||
}, 1500);
|
||||
} catch {
|
||||
// Clipboard access can be denied; the command stays selectable as text.
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,228 +1,246 @@
|
||||
---
|
||||
type FeatureCard = { icon: string; title: string; description: string; href?: string };
|
||||
|
||||
const heroFeatures: FeatureCard[] = [
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />',
|
||||
title: 'M3U / M3U8 Playlists',
|
||||
description: 'Import playlists from files or URLs with full M3U and M3U8 support.',
|
||||
href: '/iptvnator/features/m3u-player/',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />',
|
||||
title: 'Xtream Codes API',
|
||||
description: 'Connect to Xtream Codes providers with live TV, VOD, and series.',
|
||||
href: '/iptvnator/features/xtream-codes-player/',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />',
|
||||
title: 'Electronic Program Guide',
|
||||
description: 'XMLTV-based EPG with current and upcoming program information.',
|
||||
href: '/iptvnator/features/epg/',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v3" />',
|
||||
title: 'Cross-Platform',
|
||||
description: 'Runs on macOS, Windows, Linux as a desktop app or PWA in any browser.',
|
||||
href: '/iptvnator/download/',
|
||||
},
|
||||
/**
|
||||
* Features as a bento of small interface fragments. Each cell shows the piece
|
||||
* of the app it talks about — EPG rows with progress, posters with a resume
|
||||
* bar, a download queue with a REC dot — instead of an icon metaphor. Every
|
||||
* channel, program and title below is fictional demo content.
|
||||
*/
|
||||
const sources = [
|
||||
{ label: 'M3U / M3U8', href: '/iptvnator/features/m3u-player/' },
|
||||
{ label: 'Xtream Codes', href: '/iptvnator/features/xtream-codes-player/' },
|
||||
{ label: 'Stalker portal', href: '/iptvnator/features/stalker-portal-player/' },
|
||||
];
|
||||
|
||||
const carouselFeatures: FeatureCard[] = [
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.858 15.355-5.858 21.213 0" />',
|
||||
title: 'Stalker Portals',
|
||||
description: 'Browse and stream content from Stalker/Ministra portal providers.',
|
||||
href: '/iptvnator/features/stalker-portal-player/',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />',
|
||||
title: 'Favorites & History',
|
||||
description: 'Mark channels as favorites and keep track of recently watched.',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
|
||||
title: 'Search & Filter',
|
||||
description: 'Quickly find channels across playlists with full-text search.',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
title: 'Multi-Language',
|
||||
description: 'Available in 19 languages with community-contributed translations.',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
|
||||
title: 'External Players',
|
||||
description: 'Open streams in MPV, VLC, or other external players from the app.',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />',
|
||||
title: 'Light & Dark Themes',
|
||||
description: 'Choose between light and dark themes for comfortable viewing.',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />',
|
||||
title: 'Remote Control',
|
||||
description: 'Control the player from your phone with the built-in remote.',
|
||||
href: '/iptvnator/features/remote-control/',
|
||||
},
|
||||
{
|
||||
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />',
|
||||
title: 'Privacy First',
|
||||
description: 'No tracking, no analytics. Your data stays on your device.',
|
||||
},
|
||||
const guide = [
|
||||
{ badge: 'AUR', channel: 'Aurora News', program: 'Evening edition', progress: 26, ends: '19:30' },
|
||||
{ badge: 'FSS', channel: 'Fieldside Sports', program: 'Late Kickoff · match of the day', progress: 71, ends: '22:15' },
|
||||
{ badge: 'KLB', channel: 'Kitchen Lab', program: 'Sourdough, second attempt', progress: 44, ends: '20:00' },
|
||||
];
|
||||
|
||||
const posters = [
|
||||
{ title: 'Signal Lost', progress: 62, tint: 'linear-gradient(160deg, #1d4f52, #0c1a1c)' },
|
||||
{ title: 'Night Desk', progress: 18, tint: 'linear-gradient(160deg, #5a3a24, #1a120c)' },
|
||||
{ title: 'Harbor Lights', progress: 0, tint: 'linear-gradient(160deg, #3b2f6b, #12101c)' },
|
||||
];
|
||||
|
||||
const players = ['Built-in', 'Embedded MPV', 'MPV', 'VLC'];
|
||||
const platforms = [
|
||||
{ name: 'macOS', detail: 'Intel · Apple Silicon' },
|
||||
{ name: 'Windows', detail: 'installer · portable' },
|
||||
{ name: 'Linux', detail: 'AppImage · deb · rpm · Snap · Flatpak' },
|
||||
{ name: 'Web', detail: 'Docker · PWA' },
|
||||
];
|
||||
|
||||
const cellClass =
|
||||
'group relative flex flex-col gap-3 overflow-hidden rounded-xl border border-surface-800 bg-surface-900/40 p-6 transition-colors duration-300';
|
||||
const linkCellClass = `${cellClass} hover:border-surface-600 hover:bg-surface-900/70`;
|
||||
---
|
||||
|
||||
<section id="features" class="relative py-28">
|
||||
<hr class="section-divider mb-28" />
|
||||
|
||||
<section id="features" class="relative py-24 sm:py-28">
|
||||
<div class="relative z-10 mx-auto max-w-6xl px-6">
|
||||
<!-- Editorial header: left-aligned -->
|
||||
<div class="max-w-xl">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
|
||||
Features
|
||||
</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
Everything you need
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h2 class="max-w-md font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
Built like a TV app, not a file browser
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-surface-400">
|
||||
A full-featured IPTV player packed with the tools for the best viewing experience.
|
||||
<p class="max-w-sm text-base text-surface-400 sm:text-right">
|
||||
Everything a living-room player needs, on a desktop and in the browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hero feature cards: 2x2 grid -->
|
||||
<div class="stagger-children mt-16 grid grid-cols-1 gap-5 sm:grid-cols-2" data-stagger>
|
||||
{
|
||||
heroFeatures.map((feature, i) => (
|
||||
<a href={feature.href} class="feature-card group relative block overflow-hidden rounded-lg border border-surface-600/60 bg-gradient-to-br from-surface-900/80 via-surface-950/60 to-surface-900/40 p-7 transition-all duration-500 hover:border-accent-500/40">
|
||||
|
||||
<!-- Background watermark icon — large, rotated, partially clipped -->
|
||||
<div class={`feature-watermark pointer-events-none absolute -bottom-6 -right-4 opacity-[0.04] transition-all duration-700 ease-out group-hover:opacity-[0.1] ${i % 2 === 0 ? 'watermark-left' : 'watermark-right'}`}>
|
||||
<svg class="h-36 w-36 text-accent-400 sm:h-44 sm:w-44" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="0.7">
|
||||
<Fragment set:html={feature.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Watermark gradient mask — fades icon into card bg from top-left -->
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-surface-900/95 via-surface-950/40 to-transparent" />
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="relative z-[1]">
|
||||
<!-- Mono label + accent line -->
|
||||
<div class="mb-5 flex items-center gap-3">
|
||||
<span class="font-mono text-[10px] font-medium uppercase tracking-[0.2em] text-accent-500/70 transition-colors duration-300 group-hover:text-accent-400">0{i + 1}</span>
|
||||
<span class="h-px flex-1 bg-gradient-to-r from-accent-500/20 to-transparent transition-colors duration-300 group-hover:from-accent-500/40" />
|
||||
</div>
|
||||
|
||||
<h3 class="font-display text-xl font-bold tracking-tight text-surface-100 transition-colors duration-300 group-hover:text-surface-50 sm:text-2xl">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p class="mt-2.5 max-w-[85%] text-sm leading-relaxed text-surface-500 transition-colors duration-300 group-hover:text-surface-400">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Sub-header between hero and carousel -->
|
||||
<div class="mt-16">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.15em] text-surface-500">
|
||||
And more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Carousel: full-width infinite marquee -->
|
||||
<div class="relative mt-6 overflow-hidden opacity-0" data-carousel>
|
||||
<!-- Gradient masks: left and right fade -->
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 z-10 w-24 bg-gradient-to-r from-surface-950 to-transparent sm:w-32" />
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 z-10 w-24 bg-gradient-to-l from-surface-950 to-transparent sm:w-32" />
|
||||
|
||||
<!-- Marquee track -->
|
||||
<div class="carousel-track flex w-max gap-4 py-4" aria-label="Additional features">
|
||||
<!-- First set of cards -->
|
||||
{
|
||||
carouselFeatures.map((feature, i) => (
|
||||
<a href={feature.href ?? '/iptvnator/features/'} class="group relative block w-64 shrink-0 overflow-hidden rounded-lg border border-surface-600/60 bg-gradient-to-b from-surface-900/60 to-surface-950/40 p-5 transition-all duration-500 hover:border-accent-500/40">
|
||||
<div class="relative mb-3.5 inline-block">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20 transition-all duration-500 group-hover:bg-accent-500/[0.14] group-hover:ring-accent-500/35">
|
||||
<svg class="h-5 w-5 text-accent-400 transition-all duration-500 group-hover:text-accent-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<Fragment set:html={feature.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute -right-0.5 -top-0.5 h-1 w-1 rounded-full bg-accent-500/50 transition-colors duration-300 group-hover:bg-accent-400" />
|
||||
</div>
|
||||
<h3 class="font-display text-sm font-semibold tracking-tight text-surface-100 transition-colors duration-300 group-hover:text-surface-50">{feature.title}</h3>
|
||||
<p class="mt-1.5 text-xs leading-relaxed text-surface-500 transition-colors duration-300 group-hover:text-surface-400">
|
||||
{feature.description}
|
||||
</p>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
<!-- Duplicate set for seamless loop -->
|
||||
{
|
||||
carouselFeatures.map((feature, i) => (
|
||||
<div class="group relative w-64 shrink-0 overflow-hidden rounded-lg border border-surface-600/60 bg-gradient-to-b from-surface-900/60 to-surface-950/40 p-5 transition-all duration-500 hover:border-accent-500/40" aria-hidden="true">
|
||||
<div class="relative mb-3.5 inline-block">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20 transition-all duration-500 group-hover:bg-accent-500/[0.14] group-hover:ring-accent-500/35">
|
||||
<svg class="h-5 w-5 text-accent-400 transition-all duration-500 group-hover:text-accent-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<Fragment set:html={feature.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute -right-0.5 -top-0.5 h-1 w-1 rounded-full bg-accent-500/50 transition-colors duration-300 group-hover:bg-accent-400" />
|
||||
</div>
|
||||
<h3 class="font-display text-sm font-semibold tracking-tight text-surface-100 transition-colors duration-300 group-hover:text-surface-50">{feature.title}</h3>
|
||||
<p class="mt-1.5 text-xs leading-relaxed text-surface-500 transition-colors duration-300 group-hover:text-surface-400">
|
||||
{feature.description}
|
||||
</p>
|
||||
<div class="stagger-children mt-12 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-12" data-stagger>
|
||||
<!-- Sources -->
|
||||
<div class={`${cellClass} sm:col-span-2 lg:col-span-7`}>
|
||||
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Three kinds of sources, one library</h3>
|
||||
<p class="max-w-md text-sm leading-relaxed text-surface-400">
|
||||
Add an M3U file or URL, an Xtream Codes account or a Stalker portal. Paste the message your provider
|
||||
sent and IPTVnator fills in the form.
|
||||
</p>
|
||||
<div class="mt-auto pt-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{
|
||||
sources.map((source) => (
|
||||
<a
|
||||
href={source.href}
|
||||
class="rounded-md border border-surface-700 bg-surface-900 px-2.5 py-1.5 font-mono text-xs text-surface-300 transition-colors hover:border-accent-500 hover:text-accent-400"
|
||||
>
|
||||
{source.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<div class="mt-3 flex items-center gap-3 rounded-lg border border-surface-700 bg-surface-900 px-3 py-2.5 font-mono text-xs" aria-hidden="true">
|
||||
<span class="text-surface-500">URL</span>
|
||||
<span class="truncate text-surface-200">https://example.tv/get.php?type=m3u_plus</span>
|
||||
<span class="ml-auto shrink-0 text-accent-400">Add →</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Program guide -->
|
||||
<a href="/iptvnator/features/epg/" class={`${linkCellClass} lg:col-span-5`}>
|
||||
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">A program guide that follows you</h3>
|
||||
<p class="text-sm leading-relaxed text-surface-400">
|
||||
XMLTV or provider EPG: the current program in every list, a timeline under the player.
|
||||
</p>
|
||||
<ul class="mt-auto grid gap-2.5 pt-2" aria-hidden="true">
|
||||
{
|
||||
guide.map((row) => (
|
||||
<li class="grid grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-3">
|
||||
<span class="grid h-8 w-8 place-items-center rounded-md border border-surface-800 bg-surface-850 font-mono text-[10px] text-surface-500">{row.badge}</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-[13px] font-medium text-surface-100">{row.channel}</span>
|
||||
<span class="block truncate text-xs text-surface-500">{row.program}</span>
|
||||
<span class="mt-1.5 block h-[3px] overflow-hidden rounded-full bg-surface-800">
|
||||
<span class="block h-full bg-accent-500" style={`width: ${row.progress}%`} />
|
||||
</span>
|
||||
</span>
|
||||
<span class="font-mono text-[11px] tabular-nums text-surface-500">{row.ends}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</a>
|
||||
|
||||
<!-- Movies and series -->
|
||||
<a href="/iptvnator/features/xtream-codes-player/" class={`${linkCellClass} lg:col-span-4`}>
|
||||
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Movies and series</h3>
|
||||
<p class="text-sm leading-relaxed text-surface-400">Posters, seasons, resume where you stopped, TMDB details when you want them.</p>
|
||||
<div class="mt-auto flex gap-2 pt-2" aria-hidden="true">
|
||||
{
|
||||
posters.map((poster) => (
|
||||
<div class="relative aspect-[2/3] flex-1 overflow-hidden rounded-md border border-surface-800" style={`background: ${poster.tint}`}>
|
||||
<span class="absolute inset-x-2 bottom-2 font-display text-[11px] font-bold leading-tight text-white [text-shadow:0_1px_8px_rgba(0,0,0,0.7)]">{poster.title}</span>
|
||||
<span class="absolute inset-x-0 bottom-0 h-[3px] bg-white/15">
|
||||
<span class="block h-full bg-accent-500" style={`width: ${poster.progress}%`} />
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Downloads and recordings -->
|
||||
<a href="/iptvnator/blog/v0-23-release-notes/" class={`${linkCellClass} lg:col-span-4`}>
|
||||
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Downloads and recordings</h3>
|
||||
<p class="text-sm leading-relaxed text-surface-400">Keep movies offline, record live TV, watch later from the same library.</p>
|
||||
<div class="mt-auto grid gap-2.5 pt-2 text-[13px]" aria-hidden="true">
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-surface-100">Signal Lost (2019)</span>
|
||||
<span class="block font-mono text-[11px] text-surface-500">1.62 GB · 64%</span>
|
||||
</span>
|
||||
<span class="rounded-md border border-surface-700 px-2 py-1 font-mono text-[11px] text-surface-400">Paused</span>
|
||||
<span class="col-span-2 block h-[3px] overflow-hidden rounded-full bg-surface-800">
|
||||
<span class="block h-full w-[64%] bg-accent-500" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-surface-100">Aurora News</span>
|
||||
<span class="block font-mono text-[11px] text-surface-500">Recording · 00:12:41</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5 font-mono text-[11px] text-red-400">
|
||||
<span class="rec-dot h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
REC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Remote control -->
|
||||
<a href="/iptvnator/features/remote-control/" class={`${linkCellClass} lg:col-span-4`}>
|
||||
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">A remote in your pocket</h3>
|
||||
<p class="text-sm leading-relaxed text-surface-400">
|
||||
The desktop app serves a web remote to any phone on your network: channels, volume, now playing.
|
||||
</p>
|
||||
<div class="mt-auto flex items-center gap-4 pt-2" aria-hidden="true">
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
{
|
||||
['', '▲', '', '◀', 'OK', '▶', '', '▼', ''].map((key) => (
|
||||
<span
|
||||
class:list={[
|
||||
'grid h-8 w-8 place-items-center rounded-md font-mono text-[10px]',
|
||||
key ? 'border border-surface-700 bg-surface-900 text-surface-300' : '',
|
||||
key === 'OK' && 'border-accent-500 text-accent-400',
|
||||
]}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="grid gap-1 font-mono text-[11px] text-surface-500">
|
||||
<span>CH <span class="text-surface-200">+</span> / <span class="text-surface-200">−</span></span>
|
||||
<span>VOL <span class="text-surface-200">+</span> / <span class="text-surface-200">−</span></span>
|
||||
<span>Direct <span class="text-surface-200">12</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Runs everywhere / player of your choice -->
|
||||
<div class={`${cellClass} sm:col-span-2 lg:col-span-12 lg:flex-row lg:items-center lg:gap-10`}>
|
||||
<div class="lg:w-56 lg:shrink-0">
|
||||
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Runs where you are</h3>
|
||||
<p class="mt-1 text-sm text-surface-400">Same app, same library, every screen.</p>
|
||||
</div>
|
||||
<dl class="flex flex-wrap gap-x-7 gap-y-3 text-sm">
|
||||
{
|
||||
platforms.map((platform) => (
|
||||
<div class="flex items-baseline gap-2">
|
||||
<dt class="font-medium text-surface-100">{platform.name}</dt>
|
||||
<dd class="text-surface-500">{platform.detail}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
<div class="flex flex-wrap items-center gap-2 lg:ml-auto">
|
||||
<span class="mr-1 font-mono text-[11px] uppercase tracking-[0.12em] text-surface-500">Player</span>
|
||||
{
|
||||
players.map((player, i) => (
|
||||
<span
|
||||
class:list={[
|
||||
'rounded-md border px-2.5 py-1.5 font-mono text-xs',
|
||||
i === 0 ? 'border-accent-500 text-accent-400' : 'border-surface-700 text-surface-400',
|
||||
]}
|
||||
>
|
||||
{player}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Stagger animation for hero grid on scroll
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('stagger-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.1 });
|
||||
document.querySelectorAll('[data-stagger]').forEach((el) => observer.observe(el));
|
||||
|
||||
// Carousel fade-in on scroll
|
||||
const carouselEl = document.querySelector('[data-carousel]');
|
||||
if (carouselEl) {
|
||||
const co = new IntersectionObserver((entries) => {
|
||||
entries.forEach((e) => {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.remove('opacity-0');
|
||||
e.target.classList.add('animate-fade-in');
|
||||
co.unobserve(e.target);
|
||||
// Stagger the cells in as the grid scrolls into view.
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('stagger-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.05 });
|
||||
co.observe(carouselEl);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
document.querySelectorAll('[data-stagger]').forEach((el) => observer.observe(el));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.watermark-left {
|
||||
transform: rotate(-12deg) scale(1);
|
||||
.rec-dot {
|
||||
animation: rec-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.watermark-right {
|
||||
transform: rotate(12deg) scale(1);
|
||||
@keyframes rec-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.feature-card:hover .watermark-left {
|
||||
transform: rotate(-10deg) scale(1.12);
|
||||
}
|
||||
.feature-card:hover .watermark-right {
|
||||
transform: rotate(10deg) scale(1.12);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rec-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import footerWatermark from '../assets/footer-watermark.png';
|
||||
import { Image } from 'astro:assets';
|
||||
import mascotImg from '../assets/illustrations/iptvnator-1-mascot.png';
|
||||
|
||||
const socialLinks = [
|
||||
{
|
||||
@@ -23,92 +24,73 @@ const socialLinks = [
|
||||
icon: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
|
||||
},
|
||||
];
|
||||
|
||||
const footerLinks = [
|
||||
{ label: 'Features', href: '/iptvnator/features/' },
|
||||
{ label: 'Compare', href: '/iptvnator/compare/' },
|
||||
{ label: 'Download', href: '/iptvnator/download/' },
|
||||
{ label: 'Blog', href: '/iptvnator/blog/' },
|
||||
{ label: 'Unofficial sites', href: '/iptvnator/blog/beware-unofficial-iptvnator-websites/' },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="relative overflow-hidden border-t border-surface-700/60">
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute -bottom-32 -right-16 h-[28rem] w-[28rem] rounded-full bg-accent-500/[0.07] blur-3xl"></div>
|
||||
<div class="absolute -bottom-24 right-2 h-[23rem] w-[23rem]">
|
||||
<img
|
||||
src={footerWatermark.src}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="footer-watermark h-full w-full object-contain opacity-[0.075] grayscale"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<footer class="relative overflow-hidden border-t border-surface-800">
|
||||
<!-- The mascot lives here now: a sign-off, not a hero competitor -->
|
||||
<div class="pointer-events-none absolute -bottom-6 right-0 hidden w-64 opacity-70 md:block lg:w-72" aria-hidden="true">
|
||||
<Image src={mascotImg} alt="" width={520} class="h-auto w-full object-contain" loading="lazy" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mx-auto max-w-6xl px-6 py-16">
|
||||
<div class="flex flex-col items-center gap-10 md:flex-row md:justify-between">
|
||||
<!-- Logo & description -->
|
||||
<div class="text-center md:text-left">
|
||||
<div class="flex items-center justify-center gap-2.5 md:justify-start">
|
||||
<img
|
||||
src="/iptvnator/favicon-256x256.png"
|
||||
alt="IPTVnator logo"
|
||||
class="h-6 w-6"
|
||||
/>
|
||||
<div class="grid gap-10 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:pr-72">
|
||||
<div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img src="/iptvnator/favicon-256x256.png" alt="IPTVnator logo" class="h-6 w-6" />
|
||||
<span class="font-display text-lg font-semibold text-surface-100">IPTVnator</span>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-surface-500">
|
||||
Open-source IPTV player for everyone.
|
||||
<p class="mt-4 max-w-md text-sm leading-relaxed text-surface-400">
|
||||
IPTVnator is a media player. It does not provide, host or sell any media, playlists, channels or EPG
|
||||
data; channel names, logos and titles in screenshots are demo content. You are responsible for the
|
||||
sources you use and for complying with local laws and rights.
|
||||
</p>
|
||||
<div class="mt-5 flex items-center gap-1">
|
||||
{
|
||||
socialLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-lg p-2.5 text-surface-500 transition-colors duration-200 hover:bg-surface-900 hover:text-surface-100"
|
||||
aria-label={link.label}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d={link.icon} />
|
||||
</svg>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social links -->
|
||||
<div class="flex items-center gap-2">
|
||||
{
|
||||
socialLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-lg border border-transparent p-2.5 text-surface-600 transition-all duration-200 hover:border-surface-700/60 hover:text-accent-400"
|
||||
aria-label={link.label}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={link.icon} />
|
||||
</svg>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<nav aria-label="Footer">
|
||||
<ul class="grid gap-2 text-sm">
|
||||
{
|
||||
footerLinks.map((link) => (
|
||||
<li>
|
||||
<a href={link.href} class="text-surface-400 transition-colors duration-200 hover:text-surface-100">
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex flex-col items-center justify-between gap-4 border-t border-surface-700/60 pt-8 md:flex-row">
|
||||
<p class="font-mono text-[11px] text-surface-600">
|
||||
MIT License. Built with <a href="https://astro.build" class="text-surface-500 transition-colors hover:text-accent-400">Astro</a>.
|
||||
</p>
|
||||
<p class="font-mono text-[11px] text-surface-600">
|
||||
IPTVnator does not provide any playlists or content.
|
||||
<div class="mt-12 flex flex-col gap-2 border-t border-surface-800 pt-6 font-mono text-[11px] text-surface-500 sm:flex-row sm:justify-between md:pr-72">
|
||||
<p>
|
||||
MIT License · Built with <a href="https://astro.build" class="transition-colors hover:text-surface-200">Astro</a>.
|
||||
</p>
|
||||
<p>Hosted on GitHub Pages.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.footer-watermark {
|
||||
transform: rotate(-14deg) scale(1.06);
|
||||
filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.45));
|
||||
mix-blend-mode: screen;
|
||||
animation: footerMarkFloat 11s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes footerMarkFloat {
|
||||
0%, 100% {
|
||||
transform: rotate(-14deg) translate3d(0, 0, 0) scale(1.06);
|
||||
opacity: 0.065;
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-11deg) translate3d(-12px, -10px, 0) scale(1.1);
|
||||
opacity: 0.09;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.footer-watermark {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,157 +1,156 @@
|
||||
---
|
||||
import { Image } from 'astro:assets';
|
||||
import mascotImg from '../assets/illustrations/iptvnator-1-mascot.png';
|
||||
import type { ResolvedRelease } from '../lib/downloads';
|
||||
import { LATEST_RELEASE_URL } from '../lib/downloads';
|
||||
import type { GitHubStats } from '../lib/github-stats';
|
||||
import { formatCompactCount } from '../lib/github-stats';
|
||||
|
||||
const heroSocialLinks = [
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/4gray/iptvnator',
|
||||
icon: 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z',
|
||||
},
|
||||
{
|
||||
label: 'Telegram',
|
||||
href: 'https://t.me/iptvnator',
|
||||
icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.03-1.99 1.27-5.62 3.72-.53.36-1.01.54-1.44.53-.47-.01-1.38-.27-2.06-.49-.83-.27-1.49-.42-1.43-.88.03-.24.37-.49 1.02-.74 3.99-1.74 6.65-2.89 7.99-3.44 3.81-1.58 4.6-1.86 5.12-1.87.11 0 .37.03.54.17.14.12.18.28.2.45-.01.06.01.24 0 .38z',
|
||||
},
|
||||
{
|
||||
label: 'GitHub Sponsors',
|
||||
href: 'https://github.com/sponsors/4gray',
|
||||
icon: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
|
||||
},
|
||||
{
|
||||
label: 'Ko-fi',
|
||||
href: 'https://ko-fi.com/4gray',
|
||||
icon: 'M4 5h12a4 4 0 0 1 0 8h-1v2.5A4.5 4.5 0 0 1 10.5 20h-1A4.5 4.5 0 0 1 5 15.5V13H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Zm11 6h1a2 2 0 0 0 0-4h-1v4Z',
|
||||
},
|
||||
];
|
||||
interface Props {
|
||||
release: ResolvedRelease;
|
||||
stats: GitHubStats;
|
||||
}
|
||||
|
||||
const { release, stats } = Astro.props;
|
||||
|
||||
/**
|
||||
* Project numbers set in the page's own type instead of shields.io badges.
|
||||
* Anything the build could not resolve is left out rather than faked.
|
||||
*/
|
||||
const facts = [
|
||||
stats.stars !== null && { value: formatCompactCount(stats.stars), label: 'GitHub stars' },
|
||||
stats.downloads !== null && { value: formatCompactCount(stats.downloads), label: 'downloads' },
|
||||
{ value: '19', label: 'languages' },
|
||||
{ value: 'MIT', label: 'license' },
|
||||
].filter((fact): fact is { value: string; label: string } => Boolean(fact));
|
||||
|
||||
/** Demo content only: a fictional channel from the marketing fixtures, never a real broadcaster. */
|
||||
const nowPlaying = {
|
||||
channel: 'Fieldside Sports',
|
||||
program: 'Late Kickoff · match of the day',
|
||||
start: '20:30',
|
||||
end: '22:15',
|
||||
progress: 38,
|
||||
};
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden pt-36 pb-24 sm:pt-44 sm:pb-32">
|
||||
<!-- Ambient glow -->
|
||||
<div class="pointer-events-none absolute top-0 left-1/2 h-[600px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full bg-accent-500/[0.06] blur-[120px]"></div>
|
||||
<div class="pointer-events-none absolute inset-x-0 top-8 z-0 overflow-hidden opacity-35 mix-blend-screen">
|
||||
<img
|
||||
src="/iptvnator/illustrations/hero-ambient-broadcast-waves.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="mx-auto h-[18rem] w-[140%] max-w-none object-cover md:h-[22rem] md:w-full lg:h-[26rem]"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="relative overflow-hidden pt-32 sm:pt-40">
|
||||
<div class="relative mx-auto max-w-6xl px-6">
|
||||
<div class="pointer-events-none absolute -top-4 -right-12 z-0 hidden w-[18rem] opacity-75 md:block lg:w-[22rem] xl:w-[25rem]">
|
||||
<div class="absolute inset-0 rounded-full bg-accent-500/[0.16] blur-3xl"></div>
|
||||
<Image
|
||||
src={mascotImg}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width={520}
|
||||
class="relative w-full object-contain saturate-110"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-12 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:items-end">
|
||||
<div>
|
||||
<p class="flex items-center gap-2.5 font-mono text-xs uppercase tracking-[0.15em] text-surface-400">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(229,72,77,0.22)]" aria-hidden="true"></span>
|
||||
Open-source IPTV player
|
||||
<span class="text-surface-600" aria-hidden="true">·</span>
|
||||
<a href={release.url} class="transition-colors hover:text-surface-100">v{release.version}</a>
|
||||
</p>
|
||||
|
||||
<!-- Top section: editorial intro -->
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<!-- Mono label -->
|
||||
<div class="mb-8 inline-flex items-center gap-3">
|
||||
<span class="h-px w-8 bg-accent-500/50"></span>
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Open source IPTV player</span>
|
||||
<span class="h-px w-8 bg-accent-500/50"></span>
|
||||
</div>
|
||||
<!-- Headline: the one place the accent colour is allowed inside a heading -->
|
||||
<h1 class="mt-6 font-display text-5xl font-bold tracking-tight text-surface-50 sm:text-7xl lg:text-8xl">
|
||||
Every playlist,<br />one <span class="text-accent-400">player.</span>
|
||||
</h1>
|
||||
|
||||
<!-- Headline: the one place the accent colour is allowed inside a heading -->
|
||||
<h1 class="font-display text-5xl font-bold tracking-tight text-surface-50 sm:text-7xl lg:text-8xl">
|
||||
Watch, <span class="text-accent-400">your way.</span>
|
||||
</h1>
|
||||
<p class="mt-7 max-w-xl text-lg leading-relaxed text-surface-400">
|
||||
M3U playlists, Xtream Codes and Stalker portals in a single app. Live TV with a program guide,
|
||||
movies and series, downloads and recordings. On the desktop, or self-hosted in the browser.
|
||||
</p>
|
||||
|
||||
<p class="mx-auto mt-8 max-w-xl text-lg leading-relaxed text-surface-400">
|
||||
A cross-platform player for M3U/M3U8 playlists, Xtream Codes API, and
|
||||
Stalker portals. Desktop app and Progressive Web App.
|
||||
</p>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="mt-12 flex flex-col items-center gap-5">
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/releases/latest"
|
||||
class="group inline-flex items-center gap-3 rounded-xl bg-accent-500 px-8 py-4 text-base font-semibold text-surface-950 transition-colors duration-200 hover:bg-accent-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-300 sm:px-10 sm:py-5 sm:text-lg"
|
||||
>
|
||||
<svg class="h-5 w-5 transition-transform duration-300 group-hover:translate-y-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" />
|
||||
</svg>
|
||||
Download for desktop
|
||||
<span class="hidden font-mono text-[11px] font-medium uppercase tracking-[0.15em] text-surface-950/60 sm:inline">macOS · Windows · Linux</span>
|
||||
</a>
|
||||
|
||||
<!-- Secondary: self-hosted web app link -->
|
||||
<a
|
||||
href="https://github.com/4gray/iptvnator/blob/master/docker/README.md"
|
||||
class="group inline-flex items-center gap-2 text-sm font-medium text-surface-400 transition-colors duration-200 hover:text-surface-100"
|
||||
>
|
||||
Or self-host the web app
|
||||
<svg class="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Shields badges -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<img
|
||||
src="https://img.shields.io/github/stars/4gray/iptvnator?style=flat&color=20a8a8&labelColor=1a1a17"
|
||||
alt="GitHub stars"
|
||||
class="h-5"
|
||||
loading="lazy"
|
||||
/>
|
||||
<img
|
||||
src="https://img.shields.io/github/v/release/4gray/iptvnator?style=flat&color=20a8a8&labelColor=1a1a17"
|
||||
alt="Latest release"
|
||||
class="h-5"
|
||||
loading="lazy"
|
||||
/>
|
||||
<img
|
||||
src="https://img.shields.io/github/downloads/4gray/iptvnator/total?style=flat&color=20a8a8&labelColor=1a1a17"
|
||||
alt="Total downloads"
|
||||
class="h-5"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center justify-center gap-2.5">
|
||||
{heroSocialLinks.map((link) => (
|
||||
<div class="mt-9 flex flex-wrap items-center gap-3" data-hero-cta>
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group inline-flex items-center gap-2 rounded-full border border-surface-700/70 bg-surface-900/40 px-3 py-1.5 text-[11px] font-mono uppercase tracking-[0.08em] text-surface-400 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06] hover:text-accent-300"
|
||||
aria-label={link.label}
|
||||
title={link.label}
|
||||
href="/iptvnator/download/"
|
||||
class="group inline-flex items-center gap-2.5 rounded-xl bg-accent-500 px-6 py-3.5 text-[15px] font-semibold text-surface-950 transition-colors duration-200 hover:bg-accent-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-300"
|
||||
data-hero-primary
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 text-surface-500 transition-colors duration-300 group-hover:text-accent-300" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={link.icon} />
|
||||
<svg class="h-[18px] w-[18px] transition-transform duration-300 group-hover:translate-y-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" />
|
||||
</svg>
|
||||
<span>{link.label}</span>
|
||||
<span data-hero-primary-label>Download for desktop</span>
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
href="/iptvnator/download/"
|
||||
class="inline-flex items-center rounded-xl border border-surface-700 px-5 py-3.5 text-[15px] font-medium text-surface-100 transition-colors duration-200 hover:border-surface-500 hover:bg-surface-900"
|
||||
data-hero-secondary
|
||||
>
|
||||
All platforms
|
||||
</a>
|
||||
<a
|
||||
href="/iptvnator/download/docker/"
|
||||
class="group ml-1 inline-flex items-center gap-1.5 text-sm font-medium text-surface-400 transition-colors duration-200 hover:text-surface-100"
|
||||
>
|
||||
Self-host the web app
|
||||
<svg class="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<dl class="mt-10 flex flex-wrap gap-x-8 gap-y-4">
|
||||
{
|
||||
facts.map((fact) => (
|
||||
<div>
|
||||
<dt class="sr-only">{fact.label}</dt>
|
||||
<dd class="font-mono text-[15px] font-medium tabular-nums text-surface-100">{fact.value}</dd>
|
||||
<dd class="mt-0.5 font-mono text-[11px] uppercase tracking-[0.1em] text-surface-500">{fact.label}</dd>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- "Now playing": a card in the player's own language instead of a mascot or badges. Decorative. -->
|
||||
<div class="hidden justify-end lg:flex" aria-hidden="true">
|
||||
<div class="w-full max-w-sm rounded-xl border border-surface-800 bg-surface-900/60 p-4">
|
||||
<div class="flex items-center justify-between font-mono text-[11px] uppercase tracking-[0.12em] text-surface-500">
|
||||
<span>Now playing</span>
|
||||
<span class="flex items-center gap-1.5 text-red-400">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-red-500"></span>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 font-display text-base font-semibold tracking-tight text-surface-50">{nowPlaying.channel}</p>
|
||||
<p class="mt-0.5 text-[13px] text-surface-400">{nowPlaying.program}</p>
|
||||
<div class="mt-3 h-[3px] overflow-hidden rounded-full bg-surface-800">
|
||||
<div class="h-full bg-accent-500" style={`width: ${nowPlaying.progress}%`}></div>
|
||||
</div>
|
||||
<div class="mt-1.5 flex justify-between font-mono text-[11px] tabular-nums text-surface-500">
|
||||
<span>{nowPlaying.start}</span>
|
||||
<span>{nowPlaying.end}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero screenshot (the app window chrome is part of the screenshot) -->
|
||||
<div class="relative mx-auto mt-20 max-w-5xl">
|
||||
<!-- Ambient glow behind image -->
|
||||
<div class="absolute -inset-8 rounded-2xl bg-accent-500/[0.04] blur-3xl"></div>
|
||||
|
||||
<!-- Framed screenshot -->
|
||||
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-2xl shadow-black/40">
|
||||
<!-- App screenshot, lit by a blurred copy of itself: the player's own ambient mode -->
|
||||
<div class="relative mt-16 sm:mt-20">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-6 top-6 bottom-1/3 -z-10 rounded-[3rem] bg-cover bg-center opacity-35 blur-3xl saturate-150"
|
||||
style="background-image: url('/iptvnator/screenshots/screenshot-player.webp')"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div class="overflow-hidden rounded-t-xl border border-b-0 border-surface-700/60 bg-surface-900 shadow-2xl shadow-black/50">
|
||||
<img
|
||||
src="/iptvnator/screenshots/screenshot-player.webp"
|
||||
alt="IPTVnator desktop app: channel list, live player, and EPG guide in the dark theme"
|
||||
width="2442"
|
||||
height="1806"
|
||||
class="h-auto w-full"
|
||||
class="h-auto max-h-[36rem] w-full object-cover object-top"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { detectPlatform } from '../lib/detect-platform';
|
||||
import { PLATFORM_LABELS, PLATFORM_PAGES } from '../lib/platforms';
|
||||
|
||||
// Point the primary button at the visitor's own platform page. The
|
||||
// server-rendered "Download for desktop" → /download/ stays for phones
|
||||
// and anything the guess cannot place.
|
||||
const platform = detectPlatform();
|
||||
const primary = document.querySelector<HTMLAnchorElement>('[data-hero-primary]');
|
||||
const label = document.querySelector('[data-hero-primary-label]');
|
||||
if (platform && primary && label) {
|
||||
primary.href = PLATFORM_PAGES[platform];
|
||||
label.textContent = `Download for ${PLATFORM_LABELS[platform]}`;
|
||||
}
|
||||
</script>
|
||||
@@ -92,16 +92,11 @@ const channels = [
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
---
|
||||
|
||||
<section id="screenshots" class="relative py-28">
|
||||
<hr class="section-divider mb-28" />
|
||||
|
||||
<section id="screenshots" class="relative py-24 sm:py-28">
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
|
||||
Interface
|
||||
</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
<h2 class="font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
Flip through the app
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -1,86 +1,34 @@
|
||||
---
|
||||
const supportLinks = [
|
||||
{
|
||||
label: 'Give a Star',
|
||||
href: 'https://github.com/4gray/iptvnator/stargazers',
|
||||
description: 'The fastest way to help visibility',
|
||||
icon: 'M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.56L12 17.53 6.12 20.62l1.12-6.56-4.76-4.64 6.58-.96L12 2.5z',
|
||||
},
|
||||
{
|
||||
label: 'GitHub Sponsors',
|
||||
href: 'https://github.com/sponsors/4gray',
|
||||
description: 'Monthly support keeps development steady',
|
||||
icon: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
|
||||
},
|
||||
{
|
||||
label: 'Ko-fi',
|
||||
href: 'https://ko-fi.com/4gray',
|
||||
description: 'One-time donations are always appreciated',
|
||||
icon: 'M3 6.5A2.5 2.5 0 0 1 5.5 4h10A2.5 2.5 0 0 1 18 6.5V8h1.5A2.5 2.5 0 0 1 22 10.5v1A2.5 2.5 0 0 1 19.5 14H18v1.5A4.5 4.5 0 0 1 13.5 20h-6A4.5 4.5 0 0 1 3 15.5v-9zm15 5.5h1.5a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5H18V12z',
|
||||
},
|
||||
{ label: 'Star on GitHub', href: 'https://github.com/4gray/iptvnator/stargazers' },
|
||||
{ label: 'GitHub Sponsors', href: 'https://github.com/sponsors/4gray' },
|
||||
{ label: 'Ko-fi', href: 'https://ko-fi.com/4gray' },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="relative py-28">
|
||||
<hr class="section-divider mb-24" />
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 top-12 mx-auto h-56 w-[42rem] rounded-full bg-accent-500/[0.05] blur-3xl"></div>
|
||||
|
||||
<div class="relative mx-auto max-w-6xl px-6">
|
||||
<div class="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<div class="card-panel relative overflow-hidden rounded-2xl bg-surface-900/35">
|
||||
<div class="pointer-events-none absolute -top-12 -right-14 hidden h-52 w-52 opacity-35 mix-blend-screen md:block">
|
||||
<img
|
||||
src="/iptvnator/illustrations/support-open-source-signal.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Support</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
|
||||
IPTVnator stays open source
|
||||
</h2>
|
||||
<p class="mt-5 max-w-2xl text-base leading-relaxed text-surface-300 md:pr-24">
|
||||
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.
|
||||
<section class="relative pb-8">
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<div class="flex flex-col gap-6 rounded-xl border border-surface-800 bg-surface-900/40 p-6 sm:flex-row sm:items-center sm:justify-between sm:p-7">
|
||||
<div class="max-w-xl">
|
||||
<h2 class="font-display text-xl font-semibold tracking-tight text-surface-50">IPTVnator stays open source</h2>
|
||||
<p class="mt-1.5 text-sm leading-relaxed text-surface-400">
|
||||
MIT-licensed since 2020. If it saves you an evening, a star or a coffee keeps the updates coming.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{supportLinks.map((item) => (
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
{
|
||||
supportLinks.map((link) => (
|
||||
<a
|
||||
href={item.href}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group flex h-full flex-col rounded-xl border border-surface-700/70 bg-surface-950/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
|
||||
class="inline-flex items-center rounded-lg border border-surface-700 px-4 py-2.5 text-sm font-medium text-surface-100 transition-colors duration-200 hover:border-surface-500 hover:bg-surface-900"
|
||||
>
|
||||
<div class="flex min-h-[3rem] items-center gap-3 text-surface-200">
|
||||
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-accent-500/25 bg-accent-500/10">
|
||||
<svg class="h-5 w-5 text-accent-300 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d={item.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-sm font-semibold text-surface-100">{item.label}</span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-relaxed text-surface-400">{item.description}</p>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside class="card-panel rounded-2xl bg-surface-900/25">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Disclaimer</span>
|
||||
<p class="mt-4 text-sm leading-relaxed text-surface-300">
|
||||
IPTVnator is a media player application. It does not provide, host, or sell any digital media content,
|
||||
playlists, channels, or EPG data.
|
||||
</p>
|
||||
<p class="mt-3 text-sm leading-relaxed text-surface-400">
|
||||
Any channel names, logos, metadata, or playlists shown in screenshots are for demonstration purposes only.
|
||||
You are responsible for the sources you use and for complying with local laws and rights.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<GitHubStats> | undefined;
|
||||
|
||||
/** Resolves once per build; every page shares the same answer. */
|
||||
export function getGitHubStats(): Promise<GitHubStats> {
|
||||
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<GitHubStats> {
|
||||
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<unknown> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
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<string, unknown>).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<string, unknown>).assets : null;
|
||||
if (!Array.isArray(assets)) {
|
||||
continue;
|
||||
}
|
||||
for (const asset of assets) {
|
||||
const count = asset && typeof asset === 'object' ? (asset as Record<string, unknown>).download_count : null;
|
||||
if (typeof count === 'number' && Number.isFinite(count)) {
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return total > 0 ? total : null;
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<Hero />
|
||||
<Hero release={release} stats={stats} />
|
||||
<FeatureGrid />
|
||||
<ScreenshotShowcase />
|
||||
<DownloadCTA />
|
||||
<DownloadCTA release={release} />
|
||||
<SupportSection />
|
||||
|
||||
<!-- Recent Blog Posts -->
|
||||
{
|
||||
posts.length > 0 && (
|
||||
<section class="py-28">
|
||||
<hr class="section-divider mb-28" />
|
||||
|
||||
<section class="py-24 sm:py-28">
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
|
||||
Blog
|
||||
</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
<h2 class="font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
Latest updates
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-surface-400">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(/<a[^>]*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, /<dd[^>]*>19<\/dd>/, 'languages fact');
|
||||
assert.match(html, /<dd[^>]*>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(/<section id="features"[\s\S]*?<\/section>/)?.[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(/<section id="download"[\s\S]*?<\/section>/)?.[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(`<div[^>]*data-platform-row="${platform}"[\\s\\S]*?data-platform-link[^>]*>[\\s\\S]*?</a>`))?.[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(/<footer[\s\S]*?<\/footer>/)?.[0];
|
||||
assert.ok(footer, 'footer');
|
||||
assert.match(footer, /does not provide, host or sell/);
|
||||
assert.match(footer, /iptvnator-1-mascot/, 'the mascot lives in the footer');
|
||||
for (const href of ['/iptvnator/features/', '/iptvnator/compare/', '/iptvnator/download/', '/iptvnator/blog/']) {
|
||||
assert.match(footer, new RegExp(`href="${href}"`));
|
||||
}
|
||||
});
|
||||
|
||||
const AGENTS = {
|
||||
windows: {
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
|
||||
label: 'Windows',
|
||||
},
|
||||
macos: {
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
|
||||
label: 'macOS',
|
||||
},
|
||||
linux: {
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36',
|
||||
label: 'Linux',
|
||||
},
|
||||
};
|
||||
|
||||
test('browser: the download CTA and the Detected row follow the visitor OS, phones stay generic', async (t) => {
|
||||
const browser = await launchBrowser();
|
||||
if (!browser) {
|
||||
t.skip('no Chromium available for the browser half');
|
||||
return;
|
||||
}
|
||||
const { server, origin } = await serveDist();
|
||||
const readState = (page) =>
|
||||
page.evaluate(() => ({
|
||||
primary: document.querySelector('[data-hero-primary]')?.getAttribute('href'),
|
||||
label: document.querySelector('[data-hero-primary-label]')?.textContent.trim(),
|
||||
detected: [...document.querySelectorAll('[data-detected-badge]')]
|
||||
.filter((el) => !el.classList.contains('hidden'))
|
||||
.map((el) => el.closest('[data-platform-row]').dataset.platformRow),
|
||||
}));
|
||||
try {
|
||||
for (const [platform, agent] of Object.entries(AGENTS)) {
|
||||
const page = await browser.newPage({ userAgent: agent.userAgent, viewport: { width: 1440, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
await page.goto(`${origin}${BASE}/`, { waitUntil: 'networkidle' });
|
||||
const state = await readState(page);
|
||||
assert.equal(state.primary, `/iptvnator/download/${platform}/`, `${platform}: primary CTA target`);
|
||||
assert.equal(state.label, `Download for ${agent.label}`, `${platform}: primary CTA label`);
|
||||
assert.deepEqual(state.detected, [platform], `${platform}: detected row`);
|
||||
assert.deepEqual(errors, []);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
const generic = [
|
||||
{
|
||||
name: 'Android phone',
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Mobile Safari/537.36',
|
||||
viewport: { width: 390, height: 844 },
|
||||
hasTouch: true,
|
||||
},
|
||||
{
|
||||
// iPadOS asks for the desktop site with a Macintosh user agent; only the touch points give it away.
|
||||
name: 'iPad in desktop mode',
|
||||
userAgent: AGENTS.macos.userAgent.replace('Chrome/128.0 Safari/537.36', 'Version/17.5 Safari/605.1.15'),
|
||||
viewport: { width: 1024, height: 768 },
|
||||
hasTouch: true,
|
||||
},
|
||||
];
|
||||
for (const device of generic) {
|
||||
const page = await browser.newPage({ userAgent: device.userAgent, viewport: device.viewport, hasTouch: device.hasTouch });
|
||||
await page.goto(`${origin}${BASE}/`, { waitUntil: 'networkidle' });
|
||||
const state = await readState(page);
|
||||
assert.equal(state.primary, '/iptvnator/download/', `${device.name} keeps the generic CTA`);
|
||||
assert.equal(state.label, 'Download for desktop', `${device.name} keeps the generic label`);
|
||||
assert.deepEqual(state.detected, [], `${device.name} highlights no platform row`);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
// Copy buttons write the command and confirm briefly.
|
||||
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] });
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${origin}${BASE}/`, { waitUntil: 'networkidle' });
|
||||
const button = page.locator('.copy-btn').first();
|
||||
await button.scrollIntoViewIfNeeded();
|
||||
await button.click();
|
||||
assert.equal(await button.textContent(), 'Copied');
|
||||
assert.equal(await page.evaluate(() => navigator.clipboard.readText()), 'brew install --cask iptvnator');
|
||||
await context.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user