mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(website): add comparison pages for sources, players and editions
Publish /compare/ with three pages answering the choices the app asks users to make: M3U vs Xtream Codes vs Stalker portal, the built-in web players vs embedded MPV vs external MPV/VLC, and the desktop app vs the self-hosted browser version. Each opens with a one-paragraph verdict, carries a feature matrix whose cells are yes, no or a qualifier, and emits WebPage / FAQPage / BreadcrumbList JSON-LD — not SoftwareApplication, because guidance is not a product listing. These compare IPTVnator's own options against each other rather than naming other products, so every cell is checkable against this repository. Pages that name competitors remain phase 3's open half; the plan now records what has to be decided first. A registry in src/lib/comparisons.ts drives the hub, the switcher and the tests. The header gains a Compare entry and the features hub links across. tools/testing/website-compare-pages.test.mjs checks canonical URLs, the verdict block, the table, schema, cross-links and sitemap entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f1ffe59f9
commit
eb8e997c8f
16 files changed
+946
-2
No files matched your search
@@ -330,7 +330,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app
|
||||
- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E
|
||||
- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E
|
||||
- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) and feature landing pages (`/features/`, registry in `src/lib/features.ts`); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
|
||||
- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`, comparing IPTVnator's own options rather than other products); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
|
||||
- **libs/** - Shared libraries:
|
||||
- **epg/data-access** - EPG services, runtime bridge, program normalization
|
||||
- **m3u-state** - NgRx state management for M3U playlists
|
||||
|
||||
@@ -96,3 +96,22 @@ per-page switcher, the homepage feature cards and
|
||||
registry entry and one `.astro` file. Screenshots come only from the
|
||||
mock-backed guide and release captures, never from the older homepage
|
||||
screenshots that show real channel names.
|
||||
|
||||
## Comparison Pages
|
||||
|
||||
`/compare/` plus one page per decision the app asks users to make
|
||||
(`m3u-vs-xtream-vs-stalker`, `playback-engines`, `desktop-vs-browser`) live in
|
||||
`apps/website/src/pages/compare/`. They compare IPTVnator's own options against
|
||||
each other, never other products, so every claim is checkable against this
|
||||
repository; the registry is `src/lib/comparisons.ts`.
|
||||
|
||||
Each page opens with a one-paragraph verdict (`CompareHero`), carries at least
|
||||
one `ComparisonTable` (cells are `true`, `false` or a qualifying string) and
|
||||
emits `WebPage` / `FAQPage` / `BreadcrumbList` JSON-LD from
|
||||
`src/lib/comparison-schema.ts` — deliberately not `SoftwareApplication`, since
|
||||
these pages are guidance rather than a product listing, and
|
||||
`tools/testing/website-compare-pages.test.mjs` asserts that.
|
||||
|
||||
Naming a competitor on these pages is a product decision, not a technical one.
|
||||
Phase 3 of `.plans/2026-09-03-marketing-landing-pages.md` covers that and is
|
||||
still open.
|
||||
@@ -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"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
- Feature, Stalker portal player: https://4gray.github.io/iptvnator/features/stalker-portal-player/
|
||||
- Feature, TV guide (EPG): https://4gray.github.io/iptvnator/features/epg/
|
||||
- Feature, phone remote control: https://4gray.github.io/iptvnator/features/remote-control/
|
||||
- Comparisons overview: https://4gray.github.io/iptvnator/compare/
|
||||
- Compare, M3U vs Xtream Codes vs Stalker portal: https://4gray.github.io/iptvnator/compare/m3u-vs-xtream-vs-stalker/
|
||||
- Compare, playback engines (built-in vs MPV/VLC vs embedded MPV): https://4gray.github.io/iptvnator/compare/playback-engines/
|
||||
- Compare, desktop app vs self-hosted browser version: https://4gray.github.io/iptvnator/compare/desktop-vs-browser/
|
||||
- Release Notes (v0.18): https://4gray.github.io/iptvnator/blog/v0-18-release-notes/
|
||||
- Troubleshooting (macOS app damaged fix): https://4gray.github.io/iptvnator/blog/macos-arm-app-damaged-fix/
|
||||
- Source Code: https://github.com/4gray/iptvnator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
const navLinks = [
|
||||
{ label: 'Features', href: '/iptvnator/features/' },
|
||||
{ label: 'Compare', href: '/iptvnator/compare/' },
|
||||
{ label: 'Screenshots', href: '/iptvnator/#screenshots' },
|
||||
{ label: 'Download', href: '/iptvnator/download/' },
|
||||
{ label: 'Blog', href: '/iptvnator/blog/' },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
import type { ComparisonEntry } from '../../lib/comparisons';
|
||||
import { COMPARE_HUB_HREF } from '../../lib/comparisons';
|
||||
|
||||
interface Props {
|
||||
comparison: ComparisonEntry;
|
||||
titleLead: string;
|
||||
titleAccent: string;
|
||||
lede: string;
|
||||
/** The short answer, stated before the detail. */
|
||||
verdict: string;
|
||||
}
|
||||
|
||||
const { comparison, titleLead, titleAccent, lede, verdict } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden pt-36 pb-16 sm:pt-40">
|
||||
<div class="pointer-events-none absolute top-0 left-1/2 h-[520px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full bg-accent-500/[0.06] blur-[120px]"></div>
|
||||
|
||||
<div class="relative mx-auto max-w-4xl px-6">
|
||||
<nav aria-label="Breadcrumb" class="font-mono text-[11px] uppercase tracking-[0.15em] text-surface-500">
|
||||
<ol class="flex flex-wrap items-center gap-2">
|
||||
<li><a href="/iptvnator/" class="transition-colors hover:text-accent-400">IPTVnator</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li><a href={COMPARE_HUB_HREF} class="transition-colors hover:text-accent-400">Compare</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="text-surface-300" aria-current="page">{comparison.label}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<span class="mt-10 block font-mono text-xs uppercase tracking-[0.2em] text-accent-500">{comparison.eyebrow}</span>
|
||||
|
||||
<h1 class="mt-4 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
|
||||
{titleLead}{' '}
|
||||
<em class="font-medium text-accent-400" style="font-style: italic;">{titleAccent}</em>
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 text-lg leading-relaxed text-surface-400">{lede}</p>
|
||||
|
||||
<div class="mt-10 rounded-xl border border-dashed border-accent-500/35 bg-accent-500/[0.06] p-6">
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.15em] text-accent-400">Short answer</span>
|
||||
<p class="mt-2 text-base leading-relaxed text-surface-200">{verdict}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import { COMPARE_HUB_HREF, COMPARISONS } from '../../lib/comparisons';
|
||||
import type { ComparisonSlug } from '../../lib/comparisons';
|
||||
|
||||
interface Props {
|
||||
current: ComparisonSlug;
|
||||
}
|
||||
|
||||
const { current } = Astro.props;
|
||||
const others = COMPARISONS.filter((comparison) => comparison.slug !== current);
|
||||
---
|
||||
|
||||
<section class="relative py-20">
|
||||
<hr class="section-divider mb-20" />
|
||||
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Other comparisons</span>
|
||||
<h2 class="mt-3 font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
|
||||
Still{' '}
|
||||
<em class="font-medium text-accent-400" style="font-style: italic;">deciding?</em>
|
||||
</h2>
|
||||
</div>
|
||||
<a href={COMPARE_HUB_HREF} class="font-mono text-xs uppercase tracking-[0.1em] text-accent-500 transition-colors hover:text-accent-400">All comparisons</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 grid gap-4 sm:grid-cols-2">
|
||||
{others.map((comparison) => (
|
||||
<a href={comparison.href} class="card-dotted group flex flex-col gap-3 p-6">
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20 transition-all duration-300 group-hover:bg-accent-500/[0.14] group-hover:ring-accent-500/35">
|
||||
<svg class="h-4.5 w-4.5 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={comparison.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="font-display text-base font-semibold text-surface-100">{comparison.label}</span>
|
||||
<span class="text-xs leading-relaxed text-surface-500">{comparison.question}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
/**
|
||||
* Feature matrix. A cell is `true` (supported), `false` (not supported) or a
|
||||
* string when the honest answer is a qualifier rather than a yes or a no.
|
||||
*/
|
||||
export type ComparisonCell = boolean | string;
|
||||
|
||||
interface ComparisonRow {
|
||||
label: string;
|
||||
/** One cell per column, in column order. */
|
||||
cells: ComparisonCell[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Column headers; the first table column is the row label. */
|
||||
columns: string[];
|
||||
rowHeader?: string;
|
||||
rows: ComparisonRow[];
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
const { columns, rowHeader = 'Capability', rows, caption } = Astro.props;
|
||||
---
|
||||
|
||||
<figure class="not-prose">
|
||||
<div class="overflow-x-auto rounded-lg border border-dashed border-surface-600/60 bg-surface-900/40">
|
||||
<table class="w-full min-w-[720px] border-collapse text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-dashed border-surface-700/60">
|
||||
<th scope="col" class="px-5 py-4 font-mono text-[10px] uppercase tracking-[0.15em] text-surface-500">{rowHeader}</th>
|
||||
{columns.map((column) => (
|
||||
<th scope="col" class="px-5 py-4 font-mono text-[10px] uppercase tracking-[0.15em] text-accent-400">{column}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr class="border-b border-dashed border-surface-700/40 last:border-b-0 transition-colors hover:bg-surface-900/60">
|
||||
<th scope="row" class="px-5 py-4 text-left align-top text-sm font-semibold text-surface-100">
|
||||
{row.label}
|
||||
{row.note && <span class="mt-1 block text-xs font-normal leading-relaxed text-surface-500">{row.note}</span>}
|
||||
</th>
|
||||
{row.cells.map((cell) => (
|
||||
<td class="px-5 py-4 align-top text-sm text-surface-300">
|
||||
{cell === true && (
|
||||
<span class="inline-flex items-center gap-1.5 text-accent-300">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<span class="sr-only">Yes</span>
|
||||
</span>
|
||||
)}
|
||||
{cell === false && (
|
||||
<span class="inline-flex items-center gap-1.5 text-surface-600">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14" />
|
||||
</svg>
|
||||
<span class="sr-only">No</span>
|
||||
</span>
|
||||
)}
|
||||
{typeof cell === 'string' && <span class="leading-relaxed">{cell}</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{caption && <figcaption class="mt-3 text-xs leading-relaxed text-surface-500">{caption}</figcaption>}
|
||||
</figure>
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { FaqEntry } from './download-schema';
|
||||
import type { ComparisonEntry } from './comparisons';
|
||||
import { absoluteUrl } from './site';
|
||||
|
||||
interface ComparisonPageSchemaInput {
|
||||
comparison: ComparisonEntry;
|
||||
pageUrl: string;
|
||||
description: string;
|
||||
faq: FaqEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* WebPage + FAQPage + BreadcrumbList. Deliberately not SoftwareApplication:
|
||||
* these pages are guidance about choosing between options, not a product
|
||||
* listing, and marking them up as one would misdescribe them.
|
||||
*/
|
||||
export function buildComparisonPageSchema(input: ComparisonPageSchemaInput): Array<Record<string, unknown>> {
|
||||
const { comparison, pageUrl, description, faq } = input;
|
||||
|
||||
return [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: comparison.label,
|
||||
description,
|
||||
url: pageUrl,
|
||||
inLanguage: 'en',
|
||||
isPartOf: { '@type': 'WebSite', name: 'IPTVnator', url: absoluteUrl('/') },
|
||||
about: { '@type': 'SoftwareApplication', name: 'IPTVnator', applicationCategory: 'MultimediaApplication' },
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faq.map((entry) => ({
|
||||
'@type': 'Question',
|
||||
name: entry.q,
|
||||
acceptedAnswer: { '@type': 'Answer', text: entry.a },
|
||||
})),
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'IPTVnator', item: absoluteUrl('/') },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Compare', item: absoluteUrl('/compare/') },
|
||||
{ '@type': 'ListItem', position: 3, name: comparison.label, item: pageUrl },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Registry of the comparison pages under `/compare/`. These pages compare
|
||||
* IPTVnator's own options against each other — connection types, playback
|
||||
* engines, editions — rather than naming other products, so every claim on
|
||||
* them can be checked against this repository.
|
||||
*/
|
||||
|
||||
export type ComparisonSlug =
|
||||
| 'm3u-vs-xtream-vs-stalker'
|
||||
| 'playback-engines'
|
||||
| 'desktop-vs-browser';
|
||||
|
||||
export interface ComparisonEntry {
|
||||
slug: ComparisonSlug;
|
||||
/** Short name for breadcrumbs, cards and the switcher. */
|
||||
label: string;
|
||||
/** Mono eyebrow above the page title. */
|
||||
eyebrow: string;
|
||||
/** The question the page answers, one sentence. */
|
||||
question: string;
|
||||
/** Site-relative href including the GitHub Pages base. */
|
||||
href: string;
|
||||
/** 24×24 stroke icon path. */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const COMPARISONS: readonly ComparisonEntry[] = [
|
||||
{
|
||||
slug: 'm3u-vs-xtream-vs-stalker',
|
||||
label: 'M3U vs Xtream Codes vs Stalker',
|
||||
eyebrow: 'Compare · Connection types',
|
||||
question:
|
||||
'Your provider gave you a link, a login or a MAC address. Which one should you use, and what does each give you inside the app?',
|
||||
href: '/iptvnator/compare/m3u-vs-xtream-vs-stalker/',
|
||||
icon: 'M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z',
|
||||
},
|
||||
{
|
||||
slug: 'playback-engines',
|
||||
label: 'Playback engines',
|
||||
eyebrow: 'Compare · Players',
|
||||
question:
|
||||
'Built-in web players, MPV, VLC or the embedded MPV engine: which one plays your streams, and what do you give up by switching?',
|
||||
href: '/iptvnator/compare/playback-engines/',
|
||||
icon: '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 M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
},
|
||||
{
|
||||
slug: 'desktop-vs-browser',
|
||||
label: 'Desktop app vs browser version',
|
||||
eyebrow: 'Compare · Editions',
|
||||
question:
|
||||
'The desktop app and the self-hosted browser version share one codebase. Here is exactly what the browser cannot do.',
|
||||
href: '/iptvnator/compare/desktop-vs-browser/',
|
||||
icon: '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',
|
||||
},
|
||||
];
|
||||
|
||||
export const COMPARE_HUB_HREF = '/iptvnator/compare/';
|
||||
|
||||
export function comparisonBySlug(slug: ComparisonSlug): ComparisonEntry {
|
||||
const entry = COMPARISONS.find((comparison) => comparison.slug === slug);
|
||||
if (!entry) {
|
||||
throw new Error(`Unknown comparison slug: ${slug}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import FaqAccordion from '../../components/blog/FaqAccordion.astro';
|
||||
import CopyCommand from '../../components/blog/CopyCommand.astro';
|
||||
import DownloadSection from '../../components/download/DownloadSection.astro';
|
||||
import FeatureList from '../../components/download/FeatureList.astro';
|
||||
import OfficialSourcesNote from '../../components/download/OfficialSourcesNote.astro';
|
||||
import RelatedPosts from '../../components/download/RelatedPosts.astro';
|
||||
import CompareHero from '../../components/compare/CompareHero.astro';
|
||||
import ComparisonTable from '../../components/compare/ComparisonTable.astro';
|
||||
import CompareSwitcher from '../../components/compare/CompareSwitcher.astro';
|
||||
import type { FaqEntry } from '../../lib/download-schema';
|
||||
import { buildComparisonPageSchema } from '../../lib/comparison-schema';
|
||||
import { comparisonBySlug } from '../../lib/comparisons';
|
||||
import { REPO_URL } from '../../lib/downloads';
|
||||
import { absoluteUrl } from '../../lib/site';
|
||||
|
||||
const comparison = comparisonBySlug('desktop-vs-browser');
|
||||
const pageUrl = absoluteUrl('/compare/desktop-vs-browser/');
|
||||
const title = 'IPTVnator Desktop App vs Self-Hosted Browser Version – What Differs';
|
||||
const description =
|
||||
'IPTVnator ships as a desktop app for Windows, macOS and Linux and as a self-hosted browser version you run from one Docker container. Both play M3U, Xtream Codes and Stalker sources; the browser version leaves out external players, downloads, the XMLTV guide and the phone remote.';
|
||||
|
||||
const columns = ['Desktop app', 'Browser version'];
|
||||
|
||||
const rows = [
|
||||
{ label: 'M3U and M3U8 playlists', cells: [true, true] },
|
||||
{ label: 'Xtream Codes accounts', cells: [true, true] },
|
||||
{ label: 'Stalker and Ministra portals', cells: [true, true] },
|
||||
{ label: 'Program guide from a portal', cells: [true, true], note: 'Xtream and Stalker publish their own schedule.' },
|
||||
{ label: 'XMLTV guide files', cells: [true, false], note: 'Downloading, parsing and storing XMLTV needs the desktop runtime.' },
|
||||
{ label: 'Manual EPG channel mapping', cells: [true, false] },
|
||||
{ label: 'MPV, VLC and embedded MPV', cells: [true, false], note: 'A browser cannot launch another application; copy the stream URL instead.' },
|
||||
{ label: 'Offline downloads and recordings', cells: [true, false] },
|
||||
{ label: 'Phone remote control', cells: [true, false], note: 'The desktop app is what serves the remote page.' },
|
||||
{ label: 'Search across every source', cells: [true, false] },
|
||||
{ label: 'Local catalog for instant opening', cells: ['Local database', 'Browser storage'] },
|
||||
{ label: 'Opening a playlist file from the system', cells: [true, false] },
|
||||
{ label: 'Reachable from other devices', cells: [false, true], note: 'This is the one thing the browser version does better.' },
|
||||
{ label: 'Installation', cells: ['Installer or package', 'One container you run yourself'] },
|
||||
];
|
||||
|
||||
const browserFor = [
|
||||
{
|
||||
title: 'A machine you cannot install on',
|
||||
description:
|
||||
'A locked-down work laptop, a shared computer, a tablet. The browser version needs nothing installed locally beyond a browser.',
|
||||
},
|
||||
{
|
||||
title: 'A home server',
|
||||
description:
|
||||
'Run the container once and reach the same library from every device on your network. The container includes the backend that fetches playlists and proxies portal requests.',
|
||||
},
|
||||
{
|
||||
title: 'Trying it before installing',
|
||||
description:
|
||||
'The browser version is the same application, so what you see there is what the desktop app looks like, minus the features that need a desktop.',
|
||||
},
|
||||
];
|
||||
|
||||
const faq: FaqEntry[] = [
|
||||
{
|
||||
q: 'Is the browser version a different app?',
|
||||
a: 'No, it is the same application built for the browser. The desktop app wraps it and adds what only a desktop can do: launching MPV or VLC, downloading files, storing an XMLTV guide, serving the phone remote and registering playlist file types.',
|
||||
},
|
||||
{
|
||||
q: 'Why can the browser version not show an XMLTV guide?',
|
||||
a: 'A guide file is downloaded, parsed and stored on the device, which the desktop runtime does in a background worker and a local database. Portal schedules from Xtream and Stalker sources do work in the browser, because they come from the provider on demand.',
|
||||
},
|
||||
{
|
||||
q: 'How do I run the browser version?',
|
||||
a: 'With the Docker setup in the repository. It builds the web app and its backend into one image and serves them together; the compose file and the environment variables are documented in the docker directory.',
|
||||
},
|
||||
{
|
||||
q: 'Can I use both at once?',
|
||||
a: 'Yes, but they do not share data. The desktop app keeps a local database on your machine and the browser version keeps its data in that browser, so sources, favorites and history exist separately in each.',
|
||||
},
|
||||
{
|
||||
q: 'Is the browser version safe to expose to the internet?',
|
||||
a: 'Treat it as a private service. It has no user accounts, and its backend proxies requests to your providers. Run it on your own network or behind your own authenticating reverse proxy.',
|
||||
},
|
||||
{
|
||||
q: 'Which one should I install?',
|
||||
a: 'The desktop app, unless you specifically need access from other devices or cannot install software. It is the complete version and it is what most of the documentation assumes.',
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = buildComparisonPageSchema({ comparison, pageUrl, description, faq });
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={['IPTVnator docker', 'self-hosted IPTV player', 'IPTV player browser', 'IPTVnator PWA', 'IPTV player web version', 'IPTV player comparison']}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<CompareHero
|
||||
comparison={comparison}
|
||||
titleLead="Desktop app or"
|
||||
titleAccent="browser version?"
|
||||
lede="Both are IPTVnator, built from the same code. One runs on your machine and can talk to the rest of it; the other runs in a container and can be opened from anywhere on your network."
|
||||
verdict="Install the desktop app unless you need to reach IPTVnator from other devices or cannot install software. The browser version plays all three source types, but it cannot launch MPV or VLC, download files, store an XMLTV guide or serve the phone remote."
|
||||
/>
|
||||
|
||||
<DownloadSection id="matrix" eyebrow="Side by side" title="What the browser version" accent="leaves out" lead="Everything that needs to reach outside a browser tab is a desktop feature. Everything else is identical, because it is the same application.">
|
||||
<ComparisonTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowHeader="Capability"
|
||||
caption="The browser version is the self-hosted Docker build. Both editions keep your playlists and credentials on your own device or server."
|
||||
/>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="browser-for" eyebrow="When the browser wins" title="Reasons to run" accent="the container">
|
||||
<FeatureList items={browserFor} />
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="run" eyebrow="Running it" title="One container," accent="one command" lead="The image serves the web app and the backend that fetches playlists and proxies portal requests.">
|
||||
<div class="grid gap-8 lg:grid-cols-2 lg:items-start">
|
||||
<div>
|
||||
<CopyCommand title="Docker Compose" command="docker compose -f docker/docker-compose.yml up --build -d" />
|
||||
<p class="mt-4 text-sm leading-relaxed text-surface-500">
|
||||
The application is then available on port 4333. Environment variables, reverse-proxy notes and the
|
||||
limitations above are documented in{' '}
|
||||
<a href={`${REPO_URL}/blob/master/docker/README.md`} class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 transition-colors hover:text-accent-300" rel="noopener">
|
||||
the docker directory
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4 text-base leading-relaxed text-surface-400">
|
||||
<p>
|
||||
If inline playback fails in the browser, there is no external player to fall back to. Copy the stream URL and
|
||||
open it in MPV, VLC or IINA yourself; the desktop app is the version that automates that step.
|
||||
</p>
|
||||
<p>
|
||||
Data lives where the app runs: the desktop app in a local database, the browser version in that browser's own
|
||||
storage. Moving between them means adding the sources again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="faq" eyebrow="FAQ" title="Questions about" accent="the two editions">
|
||||
<div class="max-w-3xl">
|
||||
<FaqAccordion items={faq} />
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['m3u-playlist-epg-setup-guide', 'why-external-players-help', 'beware-unofficial-iptvnator-websites']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<CompareSwitcher current="desktop-vs-browser" />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import { COMPARISONS } from '../../lib/comparisons';
|
||||
import { FEATURES } from '../../lib/features';
|
||||
import { absoluteUrl } from '../../lib/site';
|
||||
|
||||
const pageUrl = absoluteUrl('/compare/');
|
||||
const title = 'Compare Your Options in IPTVnator – Sources, Players and Editions';
|
||||
const description =
|
||||
'Short, checkable comparisons for the choices IPTVnator asks you to make: which connection type to use for your provider, which playback engine plays your streams, and what the self-hosted browser version leaves out.';
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Compare your options in IPTVnator',
|
||||
description,
|
||||
url: pageUrl,
|
||||
inLanguage: 'en',
|
||||
isPartOf: { '@type': 'WebSite', name: 'IPTVnator', url: absoluteUrl('/') },
|
||||
hasPart: COMPARISONS.map((comparison) => ({
|
||||
'@type': 'WebPage',
|
||||
name: comparison.label,
|
||||
url: absoluteUrl(comparison.href.replace('/iptvnator', '')),
|
||||
})),
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'IPTVnator', item: absoluteUrl('/') },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Compare', item: pageUrl },
|
||||
],
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={['IPTV player comparison', 'M3U vs Xtream Codes', 'Stalker portal vs M3U', 'IPTV playback engines', 'self-hosted IPTV player']}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<section class="relative overflow-hidden pt-36 pb-16 sm:pt-40">
|
||||
<div class="pointer-events-none absolute top-0 left-1/2 h-[520px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full bg-accent-500/[0.06] blur-[120px]"></div>
|
||||
|
||||
<div class="relative mx-auto max-w-6xl px-6">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Compare</span>
|
||||
<h1 class="mt-4 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
|
||||
Which option is <em class="font-medium text-accent-400" style="font-style: italic;">right for you</em>
|
||||
</h1>
|
||||
<p class="mt-6 text-lg leading-relaxed text-surface-400">
|
||||
IPTVnator asks you to choose a few times: how to connect your provider, which engine plays a stream, and which
|
||||
edition to install. Each page below answers one of those with a short verdict and a table you can check.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-14 grid gap-5 md:grid-cols-3">
|
||||
{COMPARISONS.map((comparison) => (
|
||||
<a href={comparison.href} class="card-dotted group flex flex-col p-7">
|
||||
<span 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-300 group-hover:bg-accent-500/[0.14] group-hover:ring-accent-500/35">
|
||||
<svg class="h-5 w-5 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={comparison.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<h2 class="mt-5 font-display text-xl font-bold tracking-tight text-surface-50">{comparison.label}</h2>
|
||||
<p class="mt-3 flex-1 text-sm leading-relaxed text-surface-400">{comparison.question}</p>
|
||||
<span class="mt-5 inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-accent-500 transition-colors group-hover:text-accent-400">
|
||||
Read the comparison
|
||||
<svg class="h-3.5 w-3.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>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="relative py-20">
|
||||
<hr class="section-divider mb-20" />
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<div class="max-w-2xl">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Already decided?</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
|
||||
Read about a <em class="font-medium text-accent-400" style="font-style: italic;">single feature</em>
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-surface-400">Each feature page goes into detail and links to its setup guide.</p>
|
||||
</div>
|
||||
<div class="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURES.map((feature) => (
|
||||
<a href={feature.href} class="card-dotted group flex items-center gap-3 p-5">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20">
|
||||
<svg class="h-4.5 w-4.5 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d={feature.icon} />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="font-display text-sm font-semibold text-surface-100 transition-colors group-hover:text-accent-300">{feature.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import FaqAccordion from '../../components/blog/FaqAccordion.astro';
|
||||
import DownloadSection from '../../components/download/DownloadSection.astro';
|
||||
import FeatureList from '../../components/download/FeatureList.astro';
|
||||
import OfficialSourcesNote from '../../components/download/OfficialSourcesNote.astro';
|
||||
import RelatedPosts from '../../components/download/RelatedPosts.astro';
|
||||
import CompareHero from '../../components/compare/CompareHero.astro';
|
||||
import ComparisonTable from '../../components/compare/ComparisonTable.astro';
|
||||
import CompareSwitcher from '../../components/compare/CompareSwitcher.astro';
|
||||
import type { FaqEntry } from '../../lib/download-schema';
|
||||
import { buildComparisonPageSchema } from '../../lib/comparison-schema';
|
||||
import { comparisonBySlug } from '../../lib/comparisons';
|
||||
import { absoluteUrl } from '../../lib/site';
|
||||
|
||||
const comparison = comparisonBySlug('m3u-vs-xtream-vs-stalker');
|
||||
const pageUrl = absoluteUrl('/compare/m3u-vs-xtream-vs-stalker/');
|
||||
const title = 'M3U vs Xtream Codes vs Stalker Portal – Which One Should You Use?';
|
||||
const description =
|
||||
'The three ways IPTV providers hand out access, compared: an M3U playlist link, an Xtream Codes login, and a Stalker or Ministra portal with a MAC address. What each one gives you inside IPTVnator, how to tell which one you were given, and when the same account works both ways.';
|
||||
|
||||
const columns = ['M3U playlist', 'Xtream Codes', 'Stalker portal'];
|
||||
|
||||
const rows = [
|
||||
{ label: 'What you enter', cells: ['A link, a file or pasted text', 'Server URL, username, password', 'Portal URL and MAC address'] },
|
||||
{ label: 'Live TV channels', cells: [true, true, true] },
|
||||
{ label: 'Movies and series as a catalog', cells: ['Only as flat entries', true, true], note: 'A playlist can list movie files, but it carries no seasons, categories or detail pages.' },
|
||||
{ label: 'Posters, descriptions, cast', cells: ['From TMDB only', true, true] },
|
||||
{ label: 'Program guide', cells: ['XMLTV you supply', 'From the provider', 'From the portal'] },
|
||||
{ label: 'Catch-up / archive', cells: ['If the playlist declares it', true, true] },
|
||||
{ label: 'Resume where you stopped', cells: ['Movies only', true, true] },
|
||||
{ label: 'Offline downloads', cells: [false, true, true], note: 'Desktop app only, for movies and episodes.' },
|
||||
{ label: 'Local catalog for instant opening', cells: ['Whole playlist is stored', true, 'Lists are fetched on demand'] },
|
||||
{ label: 'Account and expiry information', cells: [false, true, true] },
|
||||
{ label: 'Refresh from the provider', cells: [true, true, 'Continuous'] },
|
||||
{ label: 'Works in the browser version', cells: [true, true, true], note: 'The self-hosted web version proxies all three; the desktop-only features above stay unavailable there.' },
|
||||
];
|
||||
|
||||
const identify = [
|
||||
{
|
||||
title: 'A link ending in .m3u or .m3u8',
|
||||
description:
|
||||
'A plain playlist. Add it as an M3U URL, or download it and import the file. This is the lowest common denominator: channels and nothing else.',
|
||||
},
|
||||
{
|
||||
title: 'A link containing get.php or player_api.php',
|
||||
description:
|
||||
'An Xtream Codes account in disguise. The part before the endpoint is the server URL, and the username and password sit in the query string. Add it as Xtream credentials instead of as a playlist.',
|
||||
},
|
||||
{
|
||||
title: 'A host, a username and a password',
|
||||
description:
|
||||
'An Xtream Codes account. Enter the three fields and use the connection test before adding it.',
|
||||
},
|
||||
{
|
||||
title: 'A portal address and a MAC address',
|
||||
description:
|
||||
'A Stalker or Ministra portal, the kind MAG set-top boxes use. The MAC address is the account; IPTVnator authenticates with it the way a box does.',
|
||||
},
|
||||
];
|
||||
|
||||
const faq: FaqEntry[] = [
|
||||
{
|
||||
q: 'Which one should I pick if my provider offers several?',
|
||||
a: 'Prefer Xtream Codes when you have a username and password: it carries movies, series, provider metadata, the program guide and catch-up that a flat playlist cannot. Use a Stalker portal when the provider identifies you by MAC address. Use a plain M3U playlist when that is all you were given, or when you keep a hand-made channel list.',
|
||||
},
|
||||
{
|
||||
q: 'My M3U link contains get.php. Is that an Xtream account?',
|
||||
a: 'Almost always, yes. Panels generate that link from an Xtream Codes account, so the same credentials work in the Xtream form and unlock the movie and series catalog. Paste the whole link into the Server URL field and IPTVnator fills in the username and password for you.',
|
||||
},
|
||||
{
|
||||
q: 'Can I add all three kinds of sources at once?',
|
||||
a: 'Yes. Every source is separate, with its own favorites, history and catalog, and the dashboard, global favorites, recent items and search combine them. Several accounts of the same kind are fine too.',
|
||||
},
|
||||
{
|
||||
q: 'Which one has the best program guide?',
|
||||
a: 'Xtream and Stalker portals publish their own schedule, so the guide works without any setup. An M3U playlist needs an XMLTV source, which you add once in the settings, or which the playlist declares in its header. On the desktop app your own XMLTV sources can also fill gaps in a portal guide.',
|
||||
},
|
||||
{
|
||||
q: 'Does IPTVnator convert a Stalker portal into an M3U playlist?',
|
||||
a: 'No. It talks to the portal directly for every list and resolves a fresh link for every stream, which is what keeps favorites and history working when temporary links expire. There is no export of portal content into a playlist file.',
|
||||
},
|
||||
{
|
||||
q: 'Where do I get any of these?',
|
||||
a: 'From an IPTV provider you have a contract with. IPTVnator is a player: it ships without channels, playlists or accounts, and it never sells them.',
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = buildComparisonPageSchema({ comparison, pageUrl, description, faq });
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={['M3U vs Xtream Codes', 'Xtream Codes vs Stalker portal', 'M3U or Xtream', 'IPTV connection types', 'Stalker portal vs M3U', 'IPTV player comparison']}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<CompareHero
|
||||
comparison={comparison}
|
||||
titleLead="M3U, Xtream Codes or"
|
||||
titleAccent="Stalker portal?"
|
||||
lede="Providers hand out access in three shapes, and the shape decides how much of their library you actually see. All three work in IPTVnator; they are not equally rich."
|
||||
verdict="If your provider gave you a username and a password, use Xtream Codes. If they gave you a MAC address, use the Stalker portal form. Use a plain M3U playlist only when a link is all you have, because a playlist carries channels but no movie catalog, no provider guide and no account information."
|
||||
/>
|
||||
|
||||
<DownloadSection id="matrix" eyebrow="Side by side" title="What each connection" accent="gives you" lead="Everything below is about the source type, not about the provider. A generous provider on a plain playlist still cannot send what the format cannot carry.">
|
||||
<ComparisonTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowHeader="Inside IPTVnator"
|
||||
caption="Offline downloads, the local catalog and XMLTV guides are desktop-app features; the browser version plays all three source types but leaves those out."
|
||||
/>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="identify" eyebrow="Telling them apart" title="What did your provider" accent="actually send?" lead="Providers rarely name the format. The shape of what you received does it for them.">
|
||||
<FeatureList items={identify} />
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="switching" eyebrow="Good to know" title="One account," accent="two ways in">
|
||||
<div class="max-w-3xl space-y-4 text-base leading-relaxed text-surface-400">
|
||||
<p>
|
||||
The same subscription is often reachable as both a playlist and an Xtream account, because the panel builds the
|
||||
playlist link out of the account. Adding it as Xtream credentials is strictly better: you get the same channels
|
||||
plus movies, series, the provider guide, catch-up and an expiry date, and the desktop app keeps a local catalog so
|
||||
the source opens instantly next time.
|
||||
</p>
|
||||
<p>
|
||||
Stalker portals are the exception. A MAC address is a different identity mechanism, and a portal cannot be
|
||||
expressed as a playlist or an Xtream login. If that is what you were given, the Stalker form is the only way in.
|
||||
</p>
|
||||
<p>
|
||||
Switching later costs nothing: add the better source, check that it works, and remove the old one. Favorites and
|
||||
history belong to the source, so they do not carry over.
|
||||
</p>
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="faq" eyebrow="FAQ" title="Questions about" accent="connection types">
|
||||
<div class="max-w-3xl">
|
||||
<FaqAccordion items={faq} />
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Set it up" title="One guide per" accent="connection type">
|
||||
<RelatedPosts slugs={['m3u-playlist-epg-setup-guide', 'xtream-codes-setup-guide', 'stalker-portal-setup-guide']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<CompareSwitcher current="m3u-vs-xtream-vs-stalker" />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import FaqAccordion from '../../components/blog/FaqAccordion.astro';
|
||||
import DownloadSection from '../../components/download/DownloadSection.astro';
|
||||
import FeatureList from '../../components/download/FeatureList.astro';
|
||||
import OfficialSourcesNote from '../../components/download/OfficialSourcesNote.astro';
|
||||
import RelatedPosts from '../../components/download/RelatedPosts.astro';
|
||||
import CompareHero from '../../components/compare/CompareHero.astro';
|
||||
import ComparisonTable from '../../components/compare/ComparisonTable.astro';
|
||||
import CompareSwitcher from '../../components/compare/CompareSwitcher.astro';
|
||||
import type { FaqEntry } from '../../lib/download-schema';
|
||||
import { buildComparisonPageSchema } from '../../lib/comparison-schema';
|
||||
import { comparisonBySlug } from '../../lib/comparisons';
|
||||
import { absoluteUrl } from '../../lib/site';
|
||||
|
||||
const comparison = comparisonBySlug('playback-engines');
|
||||
const pageUrl = absoluteUrl('/compare/playback-engines/');
|
||||
const title = 'Built-in Players vs MPV vs VLC in IPTVnator – Which Engine to Use';
|
||||
const description =
|
||||
'IPTVnator can play a stream in three built-in web players, in an embedded mpv engine, or in MPV or VLC on your machine. What each one decodes, which in-app controls it keeps, and what you give up by switching.';
|
||||
|
||||
const engineColumns = ['HTML5 / hls.js', 'Video.js', 'ArtPlayer'];
|
||||
|
||||
const engineRows = [
|
||||
{ label: 'HLS and MPEG-TS streams', cells: [true, true, true] },
|
||||
{ label: 'DASH with ClearKey keys', cells: [true, 'Handed to HTML5', true], note: 'A .mpd channel always plays inline; the app switches engine for it when needed.' },
|
||||
{ label: 'Shared control bar', cells: [true, true, true], note: 'On by default. Turning it off in Settings › Playback returns each player to its own controls.' },
|
||||
{ label: 'Subtitle file, offset and styling', cells: [true, false, true] },
|
||||
{ label: 'Quality selection', cells: ['Multi-variant HLS', 'Multi-variant HLS', 'Multi-variant HLS'], note: 'Offered only when the stream really carries several renditions.' },
|
||||
{ label: 'Picture-in-picture', cells: [true, true, true] },
|
||||
{ label: 'Playback error diagnostics', cells: [true, true, true] },
|
||||
];
|
||||
|
||||
const targetColumns = ['Built-in web players', 'Embedded MPV', 'MPV or VLC'];
|
||||
|
||||
const targetRows = [
|
||||
{ label: 'Where the video is drawn', cells: ['In the app window', 'In the app window', 'In a separate window'] },
|
||||
{ label: 'Codec coverage', cells: ['What the browser engine decodes', 'What mpv decodes', 'What mpv or VLC decodes'] },
|
||||
{ label: 'Needs anything installed', cells: [false, 'Bundled, except on some Linux packages', 'MPV or VLC on your machine'] },
|
||||
{ label: 'App controls, subtitles and quality menu', cells: [true, 'Controls and subtitles, no quality menu', false] },
|
||||
{ label: 'Picture-in-picture', cells: [true, false, false] },
|
||||
{ label: 'Live TV recording', cells: [false, true, false], note: 'Recordings appear beside downloads in the manager.' },
|
||||
{ label: 'Failure reaches the app', cells: [true, false, false], note: 'Only the built-in players raise a diagnostic, so the alternative-source failover for movies can act on it.' },
|
||||
{ label: 'Status', cells: ['Default', 'Experimental, opt-in', 'Stable'] },
|
||||
{ label: 'Available in the browser version', cells: [true, false, false] },
|
||||
];
|
||||
|
||||
const guidance = [
|
||||
{
|
||||
title: 'Start with the default',
|
||||
description:
|
||||
'The built-in players cover ordinary HLS and MPEG-TS streams, keep every in-app control, and are the only engines where a failed stream can trigger the automatic switch to another copy of a movie.',
|
||||
},
|
||||
{
|
||||
title: 'Reach for MPV or VLC when a stream refuses',
|
||||
description:
|
||||
'Odd codecs, unusual containers and streams that stutter in a browser engine usually play immediately in MPV or VLC. IPTVnator keeps the library, the guide and the phone remote; only the video moves to another window.',
|
||||
},
|
||||
{
|
||||
title: 'Try embedded MPV to keep it in the window',
|
||||
description:
|
||||
'The experimental engine renders mpv inside IPTVnator, so you get mpv decoding without a second window, plus live-TV recording. It is opt-in under Settings › Playback and can be switched off again.',
|
||||
},
|
||||
{
|
||||
title: 'Radio and DASH decide for themselves',
|
||||
description:
|
||||
'Radio stations always use the built-in audio player, and DASH channels always play inline, whatever engine you selected. Both ignore the external player setting on purpose.',
|
||||
},
|
||||
];
|
||||
|
||||
const faq: FaqEntry[] = [
|
||||
{
|
||||
q: 'Which player should I choose?',
|
||||
a: 'Leave the default built-in player until something refuses to play. It keeps the shared controls, subtitles, quality selection and picture-in-picture, and it is the only engine family whose failures the app can react to. Switch a stream to MPV or VLC when it does not decode.',
|
||||
},
|
||||
{
|
||||
q: 'What is the difference between MPV and embedded MPV?',
|
||||
a: 'Both use mpv to decode. MPV opens its own window and is a stable, long-standing option. Embedded MPV renders inside the IPTVnator window, keeps the app controls and can record live TV, but it is experimental and must be enabled in Settings › Playback.',
|
||||
},
|
||||
{
|
||||
q: 'Do I need to install MPV or VLC myself?',
|
||||
a: 'For the external MPV and VLC options, yes: IPTVnator launches the copy on your machine and you can point it at a specific path in the settings. The built-in players need nothing, and embedded MPV ships with the app except on the Linux packages that use your system mpv.',
|
||||
},
|
||||
{
|
||||
q: 'Why do subtitles behave differently between players?',
|
||||
a: 'Loading an external subtitle file with a timing offset and custom styling is offered where the engine can do it: the HTML5 player, ArtPlayer and embedded MPV. Video.js keeps its own subtitle handling, and MPV or VLC use theirs.',
|
||||
},
|
||||
{
|
||||
q: 'Does switching the player lose my progress?',
|
||||
a: 'No. Playback positions, favorites and history belong to the source, not the player, so a movie resumes where you stopped whichever engine plays it.',
|
||||
},
|
||||
{
|
||||
q: 'Can the browser version use MPV or VLC?',
|
||||
a: 'No. Launching another application requires the desktop app. In a browser, copy the stream URL and open it in your player manually.',
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = buildComparisonPageSchema({ comparison, pageUrl, description, faq });
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={title}
|
||||
description={description}
|
||||
keywords={['IPTV player MPV', 'IPTV player VLC', 'IPTVnator playback engine', 'hls.js IPTV player', 'embedded mpv IPTV', 'IPTV player comparison']}
|
||||
jsonLd={jsonLd}
|
||||
>
|
||||
<CompareHero
|
||||
comparison={comparison}
|
||||
titleLead="Built-in players, MPV,"
|
||||
titleAccent="or VLC?"
|
||||
lede="A stream that refuses to play is almost always a decoding problem, not a broken subscription. IPTVnator can hand the same stream to a different engine, and each engine trades something for what it gains."
|
||||
verdict="Keep the built-in player until a stream refuses to play, then send that stream to MPV or VLC. The built-in players keep every in-app control and are the only ones whose failures the app can react to; MPV and VLC decode almost anything but play in their own window."
|
||||
/>
|
||||
|
||||
<DownloadSection id="built-in" eyebrow="The built-in three" title="Web players," accent="side by side" lead="All three render inside the app and share one control bar. They differ in the extras.">
|
||||
<ComparisonTable
|
||||
columns={engineColumns}
|
||||
rows={engineRows}
|
||||
rowHeader="Capability"
|
||||
caption="Picked under Settings › Playback. The shared control bar is on by default; the legacy per-player controls remain available as an opt-out."
|
||||
/>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="targets" eyebrow="Where video plays" title="Three ways to" accent="decode a stream">
|
||||
<ComparisonTable
|
||||
columns={targetColumns}
|
||||
rows={targetRows}
|
||||
rowHeader="Capability"
|
||||
caption="Embedded MPV is an experiment and its availability differs per platform and package; the Linux builds that rely on a system mpv need it installed."
|
||||
/>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guidance" eyebrow="Choosing" title="A rule of" accent="thumb">
|
||||
<FeatureList items={guidance} />
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="faq" eyebrow="FAQ" title="Questions about" accent="playback">
|
||||
<div class="max-w-3xl">
|
||||
<FaqAccordion items={faq} />
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<DownloadSection id="guides" eyebrow="Keep reading" title="Guides and" accent="background">
|
||||
<RelatedPosts slugs={['why-external-players-help', 'embedded-mpv-macos-experiment', 'm3u-playlist-epg-setup-guide']} />
|
||||
<div class="mt-10">
|
||||
<OfficialSourcesNote />
|
||||
</div>
|
||||
</DownloadSection>
|
||||
|
||||
<CompareSwitcher current="playback-engines" />
|
||||
</BaseLayout>
|
||||
@@ -67,6 +67,12 @@ const jsonLd = [
|
||||
One player for the three ways providers hand out access, with a program guide, a phone remote and the
|
||||
comforts of a desktop app. Each page below goes into detail and links to the matching setup guide.
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-surface-500">
|
||||
Not sure which one you need?{' '}
|
||||
<a href="/iptvnator/compare/" class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 transition-colors hover:text-accent-300">
|
||||
Compare your options
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-14 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
/**
|
||||
* Structural checks for the comparison pages: canonical URLs, the
|
||||
* WebPage + FAQPage + BreadcrumbList structured data, a short verdict before
|
||||
* the detail, at least one comparison table, cross-links and sitemap entries.
|
||||
*/
|
||||
|
||||
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
|
||||
const SITE = 'https://4gray.github.io/iptvnator';
|
||||
const COMPARISONS = ['m3u-vs-xtream-vs-stalker', 'playback-engines', 'desktop-vs-browser'];
|
||||
|
||||
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
|
||||
|
||||
function extractJsonLd(html) {
|
||||
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)];
|
||||
assert.ok(blocks.length > 0, 'Expected at least one JSON-LD script block.');
|
||||
return blocks.flatMap((match) => JSON.parse(match[1]));
|
||||
}
|
||||
|
||||
for (const slug of COMPARISONS) {
|
||||
test(`comparison page ${slug}: canonical, schema and verdict`, async () => {
|
||||
const html = await readDist(`compare/${slug}/index.html`);
|
||||
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/${slug}/"`));
|
||||
assert.match(html, /<meta name="robots" content="index, follow/);
|
||||
assert.match(html, /Short answer/, 'Every comparison must state its verdict up front.');
|
||||
assert.match(html, /<table/, 'Every comparison must carry a comparison table.');
|
||||
|
||||
const schema = extractJsonLd(html);
|
||||
const page = schema.find((entry) => entry['@type'] === 'WebPage');
|
||||
assert.ok(page, 'Expected a WebPage entry.');
|
||||
assert.equal(page.url, `${SITE}/compare/${slug}/`);
|
||||
const faq = schema.find((entry) => entry['@type'] === 'FAQPage');
|
||||
assert.ok(faq && faq.mainEntity.length >= 5, 'Expected a FAQPage with at least five questions.');
|
||||
const crumbs = schema.find((entry) => entry['@type'] === 'BreadcrumbList');
|
||||
assert.equal(crumbs.itemListElement.at(-1).item, `${SITE}/compare/${slug}/`);
|
||||
assert.ok(
|
||||
!schema.some((entry) => entry['@type'] === 'SoftwareApplication'),
|
||||
'A comparison page is guidance, not a product listing.'
|
||||
);
|
||||
});
|
||||
|
||||
test(`comparison page ${slug}: links to the hub and the other comparisons`, async () => {
|
||||
const html = await readDist(`compare/${slug}/index.html`);
|
||||
assert.match(html, /href="\/iptvnator\/compare\/"/);
|
||||
for (const other of COMPARISONS.filter((candidate) => candidate !== slug)) {
|
||||
assert.match(html, new RegExp(`href="/iptvnator/compare/${other}/"`));
|
||||
}
|
||||
assert.match(html, /href="\/iptvnator\/blog\/[a-z0-9-]+\/"/, 'Expected a link into the guides.');
|
||||
});
|
||||
}
|
||||
|
||||
test('compare hub links to every comparison and to the feature pages', async () => {
|
||||
const html = await readDist('compare/index.html');
|
||||
assert.match(html, new RegExp(`<link rel="canonical" href="${SITE}/compare/"`));
|
||||
for (const slug of COMPARISONS) {
|
||||
assert.match(html, new RegExp(`href="/iptvnator/compare/${slug}/"`));
|
||||
}
|
||||
assert.match(html, /href="\/iptvnator\/features\/[a-z0-9-]+\/"/);
|
||||
});
|
||||
|
||||
test('the header and the features hub link into the comparisons', async () => {
|
||||
const home = await readDist('index.html');
|
||||
assert.match(home, /href="\/iptvnator\/compare\/"/);
|
||||
const features = await readDist('features/index.html');
|
||||
assert.match(features, /href="\/iptvnator\/compare\/"/);
|
||||
});
|
||||
|
||||
test('sitemap lists the comparison pages', async () => {
|
||||
const sitemap = await readDist('sitemap-0.xml');
|
||||
for (const path of ['compare/', ...COMPARISONS.map((slug) => `compare/${slug}/`)]) {
|
||||
assert.match(sitemap, new RegExp(`<loc>${SITE}/${path}</loc>`));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user