chore: merge master and preserve both agent guide sections

This commit is contained in:
4gray committed 2026-09-05 12:30:39 +02:00
commit aece04099e
111 files changed
+2763 -1324

No files matched your search

@@ -0,0 +1,9 @@
---
type: internal
area: dashboard
---
The dashboard's live "now playing" time range and progress bar now read
pre-computed EPG timestamps as unix seconds, matching the rest of the EPG
code. Today's dashboard lookups never carry those fields, so nothing changes
on screen; this closes the gap before a future data path supplies them.
+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: playback
issues: [871]
---
VLC now opens without an extra console window on Windows when playback progress tracking or Reuse VLC instance is enabled.
@@ -0,0 +1,7 @@
---
type: fix
area: portals
issues: [1438]
---
Xtream and Stalker portals no longer enter a connection cooldown merely because several parallel requests fail in the same millisecond. This fixes false unavailability in both the desktop app and the self-hosted web version.
+6
View File
@@ -0,0 +1,6 @@
---
type: feature
area: portals
---
Desktop settings now let you disable the temporary pause after repeated Xtream or Stalker connection failures, without restarting. Account info explains when requests are paused and offers an immediate retry.
+14
View File
@@ -177,6 +177,20 @@ agent-browser screenshot /tmp/iptvnator-cdp.png
precedence. Contract: `docs/architecture/m3u-playlist-module.md`
("User-Agent for URL sources").
## Portal Connectivity Preference
- Desktop Settings > General > Portal connections exposes default-on
`Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
settings bootstrap restores it before the renderer loads. It controls Xtream
and Stalker together. Preference transitions clear cooldowns and invalidate old
request completions; unchanged saves preserve evidence. The environment switch
`IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not
control the shared backend's guard.
- Both account-info dialogs explain guard refusals with localized paused-request
copy and Retry now; Stalker preserves cached account data on a failed refresh.
Contract: `docs/architecture/host-connectivity-guard.md`.
## Radio / Audio Player
M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected:
+15 -1
View File
@@ -345,7 +345,7 @@ This is an Nx monorepo with the following structure:
- **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
- **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)
- **shared/logging** - Dependency-free structured redaction for diagnostic logs
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance
- **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438)
- **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
- **shared/m3u-utils** - M3U playlist utilities
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/<slug>.svg`)
@@ -1637,3 +1637,17 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c
- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
<!-- nx configuration end-->
## Portal Connectivity Preference
- Desktop Settings > General > Portal connections exposes default-on
`Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
settings bootstrap restores it before the renderer loads. It controls Xtream
and Stalker together. Preference transitions clear cooldowns and invalidate old
request completions; unchanged saves preserve evidence. The environment switch
`IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not
control the shared backend's guard.
- Both account-info dialogs explain guard refusals with localized paused-request
copy and Retry now; Stalker preserves cached account data on a failed refresh.
Contract: `docs/architecture/host-connectivity-guard.md`.
@@ -0,0 +1,147 @@
import { Page } from '@playwright/test';
import { createServer } from 'node:net';
import {
closeElectronApp,
expect,
launchElectronApp,
openSettings,
openSettingsSection,
saveSettings,
test,
} from './electron-test-fixtures';
const pausedMessage = 'skipped after repeated connection failures';
// Xtream currently rejects network failures with a plain object, whose fields
// Electron discards. Stalker preserves the error code in its Error message.
const xtreamNetworkFailure =
"Error invoking remote method 'XTREAM_REQUEST': [object Object]";
async function unusedLocalOrigin(): Promise<string> {
const server = createServer();
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const address = server.address();
if (!address || typeof address === 'string')
throw new Error('No test port');
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
return `http://127.0.0.1:${address.port}`;
}
/** Real IPC/network failure: both protocols must share the saved policy. */
async function requestFailure(page: Page, origin: string, stalker = false) {
return page.evaluate(
async ({ origin, stalker }) => {
try {
if (stalker) {
await window.electron.stalkerRequest({
url: `${origin}/portal.php`,
macAddress: '00:1A:79:00:00:01',
params: { type: 'stb', action: 'handshake' },
});
} else {
await window.electron.xtreamRequest({
url: origin,
params: {
username: 'test',
password: 'test',
action: 'get_account_info',
},
});
}
return 'unexpected success';
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
},
{ origin, stalker }
);
}
test('@settings @electron saves the portal cooldown preference and applies it without restart', async ({
dataDir,
}) => {
const origin = await unusedLocalOrigin();
let app = await launchElectronApp(dataDir, {
omitEnvKeys: ['IPTVNATOR_DISABLE_CONNECTIVITY_GUARD'],
});
try {
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'general');
let checkbox = app.mainWindow
.getByTestId('portal-connectivity-toggle')
.getByRole('checkbox');
await expect(checkbox).toBeChecked();
await requestFailure(app.mainWindow, origin);
await requestFailure(app.mainWindow, origin);
expect(await requestFailure(app.mainWindow, origin, true)).toContain(
pausedMessage
);
await checkbox.uncheck();
// An unsaved edit cannot alter backend policy.
expect(await requestFailure(app.mainWindow, origin)).toContain(
pausedMessage
);
await saveSettings(app.mainWindow);
for (const stalker of [false, true, false]) {
expect(
await requestFailure(app.mainWindow, origin, stalker)
).toContain(stalker ? 'ECONNREFUSED' : xtreamNetworkFailure);
}
await expect(
app.mainWindow.locator('mat-snack-bar-container')
).toBeHidden();
await app.mainWindow
.getByTestId('portal-connectivity-setting')
.screenshot({
path: test.info().outputPath('portal-connections.png'),
});
await closeElectronApp(app);
app = await launchElectronApp(dataDir, {
omitEnvKeys: ['IPTVNATOR_DISABLE_CONNECTIVITY_GUARD'],
});
// Check the cold-start mirror before opening Settings or saving again.
for (let index = 0; index < 3; index++) {
expect(await requestFailure(app.mainWindow, origin)).toContain(
xtreamNetworkFailure
);
}
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'general');
checkbox = app.mainWindow
.getByTestId('portal-connectivity-toggle')
.getByRole('checkbox');
await expect(checkbox).not.toBeChecked();
await checkbox.check();
await saveSettings(app.mainWindow);
expect(await requestFailure(app.mainWindow, origin)).toContain(
xtreamNetworkFailure
);
expect(await requestFailure(app.mainWindow, origin, true)).toContain(
'ECONNREFUSED'
);
expect(await requestFailure(app.mainWindow, origin)).toContain(
pausedMessage
);
await app.mainWindow.getByTestId('DARK_THEME').click();
await saveSettings(app.mainWindow);
await expect(
app.mainWindow.locator('mat-snack-bar-container')
).toBeHidden();
await app.mainWindow
.getByTestId('portal-connectivity-setting')
.screenshot({
path: test.info().outputPath('portal-connections-dark.png'),
});
// Unrelated saves (such as theme changes) must preserve the cooldown.
expect(await requestFailure(app.mainWindow, origin)).toContain(
pausedMessage
);
} finally {
await closeElectronApp(app);
}
});
@@ -28,6 +28,7 @@ const STORE_KEYS = {
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
STARTUP_WINDOW_MODE: 'STARTUP_WINDOW_MODE',
PORTAL_CONNECTIVITY_GUARD: 'PORTAL_CONNECTIVITY_GUARD',
VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS',
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
} as const;
@@ -55,6 +56,7 @@ jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE,
STARTUP_WINDOW_MODE: STORE_KEYS.STARTUP_WINDOW_MODE,
PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
store: {
@@ -101,6 +103,37 @@ describe('SETTINGS_UPDATE', () => {
consoleLogSpy.mockRestore();
});
it('restores the persisted preference before the renderer starts', async () => {
mockStoreGet.mockReturnValue(false);
const settingsEvents = (await import('./settings.events')).default;
settingsEvents.bootstrapSettingsEvents();
const guard = await import('../util/host-connectivity-guard');
expect(
guard.beginGuardedHostRequest('https://portal.example')
).toBeNull();
settingsUpdateHandler({}, { portalConnectivityGuard: true });
expect(
guard.beginGuardedHostRequest('https://portal.example')
).not.toBeNull();
expect(mockStoreSet).toHaveBeenCalledWith(
STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
true
);
});
it('applies an opt-out immediately and preserves it through unrelated saves', async () => {
const guard = await import('../util/host-connectivity-guard');
settingsUpdateHandler({}, { portalConnectivityGuard: false });
settingsUpdateHandler({}, { showCaptions: true });
expect(
guard.beginGuardedHostRequest('https://portal.example')
).toBeNull();
expect(mockStoreSet).toHaveBeenCalledWith(
STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
false
);
});
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
settingsUpdateHandler(
{},
@@ -12,14 +12,19 @@ import {
MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE,
STARTUP_WINDOW_MODE,
PORTAL_CONNECTIVITY_GUARD,
store,
VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE,
} from '../services/store.service';
import { httpServer } from '../server/http-server';
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
export default class SettingsEvents {
static bootstrapSettingsEvents(): Electron.IpcMain {
setHostConnectivityGuardEnabled(
store.get(PORTAL_CONNECTIVITY_GUARD, true) !== false
);
return ipcMain;
}
}
@@ -30,6 +35,12 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
redactSensitiveData(arg)
);
if (arg.portalConnectivityGuard !== undefined) {
const enabled = arg.portalConnectivityGuard !== false;
store.set(PORTAL_CONNECTIVITY_GUARD, enabled);
setHostConnectivityGuardEnabled(enabled);
}
if (arg.mpvPlayerArguments !== undefined) {
store.set(
MPV_PLAYER_ARGUMENTS,
@@ -47,6 +47,7 @@ import { externalPlayerSessions } from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import { openVlcPlayer, shutdownVlcSession } from './vlc-session.service';
const originalPlatform = process.platform;
const spawnMock = spawn as unknown as jest.Mock;
const streamUrl = 'https://example.com/stream.m3u8';
const rcWrites: string[] = [];
@@ -136,6 +137,7 @@ describe('vlc-session.service process lifecycle', () => {
// Drop any process tracked for reuse so tests stay isolated.
shutdownVlcSession();
consoleErrorSpy.mockRestore();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
describe('instance reuse', () => {
@@ -1219,12 +1221,14 @@ describe('vlc-session.service process lifecycle', () => {
});
it('retries without the RC interface when VLC exits with code 1', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
});
const proc = createMockChildProcess();
await openTrackedVlcInstance(proc);
expect(spawnMock.mock.calls[0][1]).toContain('--rc-quiet');
const retryProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(retryProc);
@@ -1234,6 +1238,7 @@ describe('vlc-session.service process lifecycle', () => {
const retryArgs = spawnMock.mock.calls[1][1] as string[];
expect(retryArgs.join(' ')).not.toContain('--extraintf');
expect(retryArgs.join(' ')).not.toContain('--rc-host');
expect(retryArgs).not.toContain('--rc-quiet');
// Retry processes are never tracked for reuse.
expect(spawnMock.mock.calls[1][2]).toMatchObject({
detached: true,
@@ -1262,6 +1267,7 @@ describe('vlc-session.service process lifecycle', () => {
});
it('retries without the RC interface after a start error', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
@@ -1272,11 +1278,14 @@ describe('vlc-session.service process lifecycle', () => {
const openPromise = openVlcPlayer({ title: 'S', url: streamUrl });
await waitForSpawnCallCount(1);
expect(spawnMock.mock.calls[0][1]).toContain('--rc-quiet');
proc.emit('error', new Error('rc unsupported'));
await waitForSpawnCallCount(2);
const retryArgs = spawnMock.mock.calls[1][1] as string[];
expect(retryArgs.join(' ')).not.toContain('--extraintf');
expect(retryArgs.join(' ')).not.toContain('--rc-host');
expect(retryArgs).not.toContain('--rc-quiet');
retryProc.emit('spawn');
const session = await openPromise;
expect(session.status).toBe('opened');
@@ -39,6 +39,7 @@ import { spawn, type ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { createServer } from 'net';
import {
VLC_PLAYER_ARGUMENTS,
VLC_PLAYER_PATH,
VLC_REUSE_INSTANCE,
store,
@@ -51,6 +52,7 @@ import {
shutdownVlcSession,
} from './vlc-session.service';
const originalPlatform = process.platform;
const spawnMock = spawn as unknown as jest.Mock;
const streamUrl = 'https://example.com/stream.m3u8';
@@ -86,6 +88,8 @@ describe('vlc-session.service helpers and launch args', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.defineProperty(process, 'platform', { value: 'darwin' });
jest.useFakeTimers({ doNotFake: ['setImmediate'] });
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
(createServer as unknown as jest.Mock).mockImplementation(() => ({
unref: jest.fn(),
@@ -103,7 +107,10 @@ describe('vlc-session.service helpers and launch args', () => {
afterEach(() => {
// Drop any process tracked for reuse so tests stay isolated.
shutdownVlcSession();
jest.clearAllTimers();
jest.useRealTimers();
consoleErrorSpy.mockRestore();
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
describe('buildVlcEnqueueCommands', () => {
@@ -177,6 +184,152 @@ describe('vlc-session.service helpers and launch args', () => {
});
describe('openVlcPlayer launch args', () => {
it.each(['error', 'exit'])(
'preserves data containing --rc-quiet during an RC retry (%s)',
async (event) => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
[VLC_PLAYER_ARGUMENTS]: '--fullscreen\n--rc-quiet',
});
const proc = createMockChildProcess();
const retryProc = createMockChildProcess();
spawnMock
.mockReturnValueOnce(proc)
.mockReturnValueOnce(retryProc);
const url = `${streamUrl}?option=--rc-quiet`;
const openPromise = openVlcPlayer({
title: 'Movie --rc-quiet',
url,
userAgent: 'Agent --rc-quiet',
});
await waitForSpawnCallCount(1);
proc.emit('spawn');
await openPromise;
proc.emit(event, event === 'exit' ? 1 : new Error('RC failed'));
await waitForSpawnCallCount(2);
expect(spawnMock.mock.lastCall[1]).toEqual([
'--fullscreen',
// Custom arguments are preserved; only the managed flag goes.
'--rc-quiet',
':http-user-agent=Agent --rc-quiet',
url,
':meta-title=Movie --rc-quiet',
]);
retryProc.emit('spawn');
retryProc.emit('exit', 0);
}
);
it.each(['win32', 'darwin', 'linux'] as const)(
'adds quiet mode only for managed Windows RC launches (%s)',
async (platform) => {
Object.defineProperty(process, 'platform', { value: platform });
for (const reuseInstance of [false, true]) {
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: reuseInstance,
});
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
const openPromise = openVlcPlayer({
title: 'RC Stream',
url: streamUrl,
// Exercise progress-only and reuse-only RC separately.
...(!reuseInstance && {
contentInfo: {
playlistId: 'playlist',
contentXtreamId: 1,
contentType: 'vod' as const,
},
}),
});
await waitForSpawnCallCount(reuseInstance ? 2 : 1);
proc.emit('spawn');
await openPromise;
expect(spawnMock.mock.lastCall[1]).toEqual([
'--extraintf=rc',
'--rc-host=127.0.0.1:43210',
...(platform === 'win32' ? ['--rc-quiet'] : []),
streamUrl,
':meta-title=RC Stream',
]);
expect(spawnMock.mock.lastCall[2].detached).toBe(
!reuseInstance
);
proc.emit('exit', 0);
}
}
);
it.each(['--rc-quiet', '--no-rc-quiet'])(
'keeps custom arguments before managed quiet mode (%s)',
async (customQuiet) => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: true,
[VLC_PLAYER_ARGUMENTS]: `--fullscreen\n${customQuiet}`,
});
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
const openPromise = openVlcPlayer({
title: 'Custom',
url: streamUrl,
});
await waitForSpawnCallCount(1);
proc.emit('spawn');
await openPromise;
expect(spawnMock.mock.lastCall[1]).toEqual([
'--fullscreen',
customQuiet,
'--extraintf=rc',
'--rc-host=127.0.0.1:43210',
'--rc-quiet',
streamUrl,
':meta-title=Custom',
]);
proc.emit('exit', 0);
}
);
it.each([false, true])(
'does not add quiet mode without an RC port (reuse: %s)',
async (reuseInstance) => {
Object.defineProperty(process, 'platform', { value: 'win32' });
mockStoreValues({
[VLC_PLAYER_PATH]: '/usr/bin/vlc',
[VLC_REUSE_INSTANCE]: reuseInstance,
});
(createServer as unknown as jest.Mock).mockImplementation(
() => {
throw new Error('No free port');
}
);
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
const openPromise = openVlcPlayer({
title: 'No RC',
url: streamUrl,
});
await waitForSpawnCallCount(1);
proc.emit('spawn');
await openPromise;
expect(spawnMock.mock.lastCall[1]).toEqual([
streamUrl,
':meta-title=No RC',
]);
expect(createServer).toHaveBeenCalledTimes(
reuseInstance ? 1 : 0
);
}
);
it('spawns a detached VLC process with http options and start time', async () => {
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
@@ -249,6 +249,10 @@ export async function openVlcPlayer({
if (rcPort > 0) {
args.push('--extraintf=rc');
args.push(`--rc-host=127.0.0.1:${rcPort}`);
if (process.platform === 'win32') {
// Keep TCP control without VLC's separate DOS console.
args.push('--rc-quiet');
}
}
if (effectiveUserAgent) {
@@ -521,7 +525,7 @@ export async function openVlcPlayer({
(arg) =>
!arg.includes('--extraintf') &&
!arg.includes('--rc-host') &&
!arg.includes('--rc-quiet')
arg !== '--rc-quiet'
);
spawnVlc(retryArgs, true);
} else {
@@ -572,7 +576,7 @@ export async function openVlcPlayer({
(arg) =>
!arg.includes('--extraintf') &&
!arg.includes('--rc-host') &&
!arg.includes('--rc-quiet')
arg !== '--rc-quiet'
);
spawnVlc(retryArgs, true);
return;
@@ -25,6 +25,9 @@ export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
*/
export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
/** Desktop portal request cooldown; absent means enabled. */
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
/**
* Extra libmpv options for embedded sessions, one "key=value" per line, as
* typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler
@@ -52,6 +55,7 @@ export type StoreType = {
[EMBEDDED_MPV_EXTRA_OPTIONS]: string;
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
[PORTAL_CONNECTIVITY_GUARD]: boolean;
};
// Export singleton store instance
@@ -9,6 +9,8 @@ import {
beginGuardedHostRequest,
reportGuardedHostFailure,
resetHostConnectivityGuardForTests,
setHostConnectivityGuardEnabled,
reportGuardedHostSuccess,
} from './host-connectivity-guard';
describe('reportGuardedHostFailure', () => {
@@ -49,6 +51,62 @@ describe('reportGuardedHostFailure', () => {
resetHostConnectivityGuardForTests();
});
it('bypasses an open guard when disabled and starts fresh when re-enabled', () => {
attempt(ownFailure());
attempt(ownFailure());
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
setHostConnectivityGuardEnabled(false);
expect(beginGuardedHostRequest(URL_ON_ENDPOINT)).toBeNull();
setHostConnectivityGuardEnabled(true);
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow();
attempt(ownFailure());
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow();
});
it('does not reset the guard when saving an unchanged preference', () => {
attempt(ownFailure());
attempt(ownFailure());
setHostConnectivityGuardEnabled(true);
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
});
it('ignores old failures and successes after a preference transition', () => {
const oldFailure = beginGuardedHostRequest(URL_ON_ENDPOINT);
const oldSuccess = beginGuardedHostRequest(URL_ON_ENDPOINT);
setHostConnectivityGuardEnabled(false);
setHostConnectivityGuardEnabled(true);
reportGuardedHostFailure(oldFailure, ownFailure());
reportGuardedHostFailure(oldFailure, ownFailure());
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow();
attempt(ownFailure());
attempt(ownFailure());
reportGuardedHostSuccess(oldSuccess);
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
});
it('keeps the environment disable switch authoritative', () => {
const original = process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD;
process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD = '1';
try {
setHostConnectivityGuardEnabled(true);
attempt(ownFailure());
attempt(ownFailure());
expect(() =>
beginGuardedHostRequest(URL_ON_ENDPOINT)
).not.toThrow();
} finally {
if (original === undefined)
delete process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD;
else process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD = original;
}
});
it('does not charge a same-origin redirect hop to the guarded endpoint', () => {
// `/player_api.php` -> 302 -> `/slow/player_api.php` on the same origin:
// the origin answered, so its record must clear even though the failing
@@ -22,6 +22,16 @@ export { HostConnectivityGuardError };
export type { HostRequestToken };
let sharedGuard: HostConnectivityGuard | null = null;
let enabled = true;
let currentTokens = new WeakSet<HostRequestToken>();
/** Apply a saved desktop preference without letting old requests affect a new guard. */
export function setHostConnectivityGuardEnabled(value: boolean): void {
if (enabled === value) return;
enabled = value;
sharedGuard = null;
currentTokens = new WeakSet<HostRequestToken>();
}
/** The guard both portal IPC handlers share. */
export function getHostConnectivityGuard(): HostConnectivityGuard {
@@ -40,6 +50,8 @@ export function getHostConnectivityGuard(): HostConnectivityGuard {
/** Test seam: forgets the shared guard so each spec starts clean. */
export function resetHostConnectivityGuardForTests(): void {
enabled = true;
currentTokens = new WeakSet<HostRequestToken>();
sharedGuard = null;
}
@@ -53,7 +65,7 @@ export function resetHostConnectivityGuardForTests(): void {
*/
export function beginGuardedHostRequest(url: string): HostRequestToken | null {
const endpoint = portalEndpointKeyOf(url);
if (!endpoint) {
if (!endpoint || !enabled) {
return null;
}
@@ -62,6 +74,7 @@ export function beginGuardedHostRequest(url: string): HostRequestToken | null {
throw new HostConnectivityGuardError(endpoint);
}
currentTokens.add(check.token);
return check.token;
}
@@ -70,11 +83,14 @@ export function observeGuardedHostRequest(
url: string
): HostRequestToken | null {
const endpoint = portalEndpointKeyOf(url);
return endpoint ? getHostConnectivityGuard().observe(endpoint) : null;
if (!endpoint || !enabled) return null;
const token = getHostConnectivityGuard().observe(endpoint);
currentTokens.add(token);
return token;
}
export function reportGuardedHostSuccess(token: HostRequestToken | null): void {
if (token) {
if (token && currentTokens.has(token)) {
getHostConnectivityGuard().reportSuccess(token);
}
}
@@ -91,7 +107,7 @@ export function reportGuardedHostFailure(
error: unknown,
options: { countFailures?: boolean; requestUrl?: string } = {}
): void {
if (!token) {
if (!token || !currentTokens.has(token)) {
return;
}
@@ -8,6 +8,16 @@ import {
describe('settings form utils — strip country prefix', () => {
const formBuilder = new FormBuilder();
it('defaults portal request pauses on and preserves an explicit opt-out', () => {
const form = createSettingsForm(new FormBuilder(), true);
expect(form.get('portalConnectivityGuard')?.value).toBe(true);
form.patchValue({ portalConnectivityGuard: false });
expect(
createSettingsFromFormValue(form, {} as Settings)
.portalConnectivityGuard
).toBe(false);
});
it('defaults the form control to false', () => {
const form = createSettingsForm(formBuilder, true);
@@ -107,6 +107,7 @@ export function createSettingsForm(
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: ['', [embeddedMpvExtraOptionsValidator]],
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium' as CoverSize,
...(supportsEpg
? {
@@ -195,6 +196,7 @@ export function createSettingsFromFormValue(
value.embeddedMpvExtraOptions
),
embeddedMpvAutoReconnect: value.embeddedMpvAutoReconnect ?? true,
portalConnectivityGuard: value.portalConnectivityGuard !== false,
coverSize: value.coverSize ?? 'medium',
epgUrl,
preferUploadedEpgOverXtream:
@@ -1,8 +1,4 @@
<section
class="settings-group"
id="general"
[formGroup]="form()"
>
<section class="settings-group" id="general" [formGroup]="form()">
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>settings</mat-icon>
@@ -204,3 +200,48 @@
</div>
</div>
</section>
@if (supportsPortalConnectivityGuard()) {
<section
class="settings-group"
[formGroup]="form()"
data-test-id="portal-connectivity-setting"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>hub</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}</h3>
</div>
</header>
<div class="setting-item">
<div class="setting-item__meta">
<h4 id="portal-connectivity-label">
{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }}
</h4>
<p id="portal-connectivity-description">
{{
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DESCRIPTION'
| translate
}}
</p>
@if (form().get('portalConnectivityGuard')?.value === false) {
<p>
{{
'SETTINGS.PORTAL_CONNECTIVITY_GUARD_DISABLED'
| translate
}}
</p>
}
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="portalConnectivityGuard"
aria-labelledby="portal-connectivity-label"
aria-describedby="portal-connectivity-description"
data-test-id="portal-connectivity-toggle"
></mat-checkbox>
</div>
</div>
</section>
}
@@ -39,6 +39,7 @@ export class SettingsGeneralSectionComponent {
input.required<StartupWindowModeOption[]>();
/** Desktop only: the window-mode select needs the main-process mirror and F11 */
readonly supportsStartupWindowMode = input(false);
readonly supportsPortalConnectivityGuard = input(false);
readonly selectTheme = output<Theme>();
readonly selectCoverSize = output<CoverSize>();
@@ -59,6 +59,40 @@ describe('SettingsComponent form', () => {
window.electron = originalElectron;
});
it('stages the desktop portal pause setting until Save', async () => {
const checkbox = (fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="portal-connectivity-toggle"] input'
) as HTMLInputElement;
expect(checkbox).not.toBeNull();
expect(checkbox.checked).toBe(true);
checkbox.click();
fixture.detectChanges();
expect(
component.settingsForm.get('portalConnectivityGuard')?.value
).toBe(false);
expect(window.electron.updateSettings).not.toHaveBeenCalled();
component.form.hydrateFromStore();
fixture.detectChanges();
expect(checkbox.checked).toBe(true);
checkbox.click();
await component.form.save(() => undefined);
expect(window.electron.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ portalConnectivityGuard: false })
);
});
it('hides the portal pause setting when the desktop bridge is unavailable', () => {
fixture.destroy();
window.electron = undefined;
fixture = TestBed.createComponent(SettingsComponent);
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="portal-connectivity-setting"]'
)
).toBeNull();
});
describe('Get and set settings on component init', () => {
const settings = {
language: Language.GERMAN,
@@ -111,6 +111,9 @@
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
[startupWindowModeOptions]="startupWindowModeOptions"
[supportsPortalConnectivityGuard]="
supportsPortalConnectivityGuard
"
[supportsStartupWindowMode]="supportsStartupWindowMode"
(selectTheme)="form.selectTheme($event)"
(selectCoverSize)="form.selectCoverSize($event)"
@@ -142,6 +142,8 @@ export class SettingsComponent
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode;
readonly supportsPortalConnectivityGuard =
this.runtime.supportsPortalConnectivityGuard;
/** Settings form object */
readonly settingsForm = this.form.form;
@@ -112,6 +112,7 @@ export const DEFAULT_SETTINGS = {
embeddedMpvFrameCopy: false,
embeddedMpvExtraOptions: '',
embeddedMpvAutoReconnect: true,
portalConnectivityGuard: true,
coverSize: 'medium',
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
@@ -222,6 +223,9 @@ export function createEpgBridgeStub(): Partial<EpgRuntimeBridgeService> {
/** The full desktop bridge the settings page expects to be present */
export function createElectronStub(): typeof window.electron {
return {
resetHostConnectivityGuard: jest
.fn()
.mockResolvedValue({ success: true }),
checkEpgFreshness: jest.fn().mockResolvedValue({
freshUrls: [],
staleUrls: [],
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "اتصالات البوابات",
"PORTAL_CONNECTIVITY_GUARD": "إيقاف الطلبات إلى البوابات غير المتاحة مؤقتًا",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "بعد تكرار فشل الاتصال، تُوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تعطيل هذا الخيار إذا كانت البوابة تصبح غير متاحة بشكل متقطع. يُطبّق بعد الحفظ دون إعادة التشغيل.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "سيُنفّذ كل طلب بشكل مستقل. قد يستغرق ظهور الخطأ وقتًا أطول عند عدم توفر الخادم.",
"TITLE": "الإعدادات",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "تغيير تكوين التطبيق",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "الطلبات متوقفة مؤقتًا",
"DESCRIPTION": "أُوقفت الطلبات بعد تكرار فشل الاتصال. أعد المحاولة للاتصال بالبوابة فورًا.",
"RETRY": "إعادة المحاولة الآن"
},
"ALL_CATEGORIES": "جميع الفئات",
"MY_FAVORITES": "المفضلة",
"ADD_TO_FAVORITES": "إضافة للمفضلة",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "الاتصال بالبوابات",
"PORTAL_CONNECTIVITY_GUARD": "وقّف مؤقتاً الطلبات للبوابات اللي ما متاحاش",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "من بعد ما يفشل الاتصال عدة مرات، كيوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تطفي هاد الخيار إلا كانت البوابة كتقطع مرة مرة. كيتطبق من بعد الحفظ بلا إعادة التشغيل.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "كل طلب غادي يتنفذ بوحدو. إلا كان السيرفر ما متاحش، رسالة الخطأ تقدر تتأخر.",
"TITLE": "الإعدادات",
"GENERAL": "عام",
"GENERAL_SUBTITLE": "بدل تكوين التطبيق",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "الطلبات موقوفة مؤقتاً",
"DESCRIPTION": "توقفات الطلبات من بعد ما فشل الاتصال عدة مرات. عاود جرّب باش تتاصل بالبوابة دابا.",
"RETRY": "عاود جرّب دابا"
},
"ALL_CATEGORIES": "جميع الفئات",
"MY_FAVORITES": "المفضلة ديالي",
"ADD_TO_FAVORITES": "زيد للمفضلة",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Падключэнне да парталаў",
"PORTAL_CONNECTIVITY_GUARD": "Прыпыняць запыты да недаступных парталаў",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Пасля паўторных памылак падключэння прыпыняць новыя запыты Xtream і Stalker на 30 секунд. Паспрабуйце выключыць, калі партал перыядычна становіцца недаступным. Ужываецца пасля захавання без перазапуску.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Кожны запыт будзе выконвацца незалежна. Калі сервер недаступны, чаканне памылкі можа павялічыцца.",
"TITLE": "Налады",
"GENERAL": "Агульныя",
"GENERAL_SUBTITLE": "Змяніце канфігурацыю праграмы",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Запыты часова прыпынены",
"DESCRIPTION": "Запыты прыпынены пасля паўторных памылак падключэння. Паўтарыце спробу, каб звярнуцца да партала зараз.",
"RETRY": "Паўтарыць зараз"
},
"ALL_CATEGORIES": "Усе катэгорыі",
"MY_FAVORITES": "Выбранае",
"ADD_TO_FAVORITES": "Дадаць у выбранае",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Portalverbindungen",
"PORTAL_CONNECTIVITY_GUARD": "Anfragen an nicht erreichbare Portale pausieren",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Nach wiederholten Verbindungsfehlern neue Xtream- und Stalker-Anfragen 30 Sekunden pausieren. Bei zeitweise nicht erreichbaren Portalen versuchsweise deaktivieren. Gilt nach dem Speichern ohne Neustart.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Jede Anfrage wird unabhängig ausgeführt. Bei nicht erreichbaren Servern kann die Fehlermeldung länger dauern.",
"TITLE": "Einstellungen",
"GENERAL": "Allgemein",
"GENERAL_SUBTITLE": "Allgemeine App-Einstellungen ändern",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Anfragen vorübergehend pausiert",
"DESCRIPTION": "Anfragen wurden nach wiederholten Verbindungsfehlern pausiert. Jetzt erneut versuchen, um das Portal sofort zu kontaktieren.",
"RETRY": "Jetzt erneut versuchen"
},
"ALL_CATEGORIES": "Alle Kategorien",
"MY_FAVORITES": "Meine Favoriten",
"ADD_TO_FAVORITES": "Zu den Favoriten hinzufügen",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Συνδέσεις πυλών",
"PORTAL_CONNECTIVITY_GUARD": "Παύση αιτημάτων προς μη διαθέσιμες πύλες",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Μετά από επανειλημμένα σφάλματα σύνδεσης, παύση νέων αιτημάτων Xtream και Stalker για 30 δευτερόλεπτα. Δοκιμάστε να το απενεργοποιήσετε αν μια πύλη είναι κατά διαστήματα μη διαθέσιμη. Ισχύει μετά την αποθήκευση, χωρίς επανεκκίνηση.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Κάθε αίτημα εκτελείται ανεξάρτητα. Οι μη διαθέσιμοι διακομιστές μπορεί να αργούν περισσότερο να αναφέρουν σφάλμα.",
"TITLE": "Ρυθμίσεις",
"GENERAL": "Γενικά",
"GENERAL_SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Τα αιτήματα έχουν παύσει προσωρινά",
"DESCRIPTION": "Τα αιτήματα σταμάτησαν μετά από επανειλημμένα σφάλματα σύνδεσης. Δοκιμάστε ξανά για άμεση επικοινωνία με την πύλη.",
"RETRY": "Νέα προσπάθεια τώρα"
},
"ALL_CATEGORIES": "Όλες οι κατηγορίες",
"MY_FAVORITES": "Τα αγαπημένα μου",
"ADD_TO_FAVORITES": "Προσθήκη στα αγαπημένα",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Portal connections",
"PORTAL_CONNECTIVITY_GUARD": "Pause requests to unavailable portals",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "After repeated connection failures, pause new Xtream and Stalker requests for 30 seconds. Try disabling this if a portal becomes unavailable intermittently. Applies after saving, without restarting.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Each request will run independently. Unavailable servers may take longer to report an error.",
"TITLE": "Settings",
"GENERAL": "General",
"GENERAL_SUBTITLE": "Change the configuration of the application",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Requests temporarily paused",
"DESCRIPTION": "Requests were paused after repeated connection failures. Retry now to contact the portal immediately.",
"RETRY": "Retry now"
},
"ALL_CATEGORIES": "All categories",
"MY_FAVORITES": "My favorites",
"ADD_TO_FAVORITES": "Add to favorites",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Conexiones a portales",
"PORTAL_CONNECTIVITY_GUARD": "Pausar solicitudes a portales no disponibles",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tras varios fallos de conexión, pausar las nuevas solicitudes de Xtream y Stalker durante 30 segundos. Prueba a desactivarlo si un portal deja de estar disponible de forma intermitente. Se aplica al guardar, sin reiniciar.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada solicitud se ejecutará de forma independiente. Los servidores no disponibles pueden tardar más en mostrar un error.",
"TITLE": "Ajustes",
"GENERAL": "General",
"GENERAL_SUBTITLE": "Cambia la configuracion general de la aplicacion",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Solicitudes pausadas temporalmente",
"DESCRIPTION": "Las solicitudes se pausaron tras varios fallos de conexión. Reintenta para contactar con el portal inmediatamente.",
"RETRY": "Reintentar ahora"
},
"ALL_CATEGORIES": "Todas las categorías",
"MY_FAVORITES": "Mis favoritos",
"ADD_TO_FAVORITES": "Agregar a los favoritos",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Connexions aux portails",
"PORTAL_CONNECTIVITY_GUARD": "Suspendre les requêtes aux portails indisponibles",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Après plusieurs échecs de connexion, suspendre les nouvelles requêtes Xtream et Stalker pendant 30 secondes. Essayez de désactiver cette option si un portail devient indisponible par intermittence. Prend effet après enregistrement, sans redémarrage.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Chaque requête sera exécutée indépendamment. Les serveurs indisponibles peuvent mettre plus de temps à signaler une erreur.",
"TITLE": "Paramètres",
"GENERAL": "Général",
"GENERAL_SUBTITLE": "Modifier la configuration de l'application",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Requêtes temporairement suspendues",
"DESCRIPTION": "Les requêtes ont été suspendues après plusieurs échecs de connexion. Réessayez pour contacter le portail immédiatement.",
"RETRY": "Réessayer maintenant"
},
"ALL_CATEGORIES": "Toutes les catégories",
"MY_FAVORITES": "Mes favoris",
"ADD_TO_FAVORITES": "Ajouter aux favoris",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Portálkapcsolatok",
"PORTAL_CONNECTIVITY_GUARD": "Kérések szüneteltetése az elérhetetlen portálokhoz",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Ismételt kapcsolódási hibák után az új Xtream- és Stalker-kérések szüneteltetése 30 másodpercre. Ha egy portál időnként elérhetetlenné válik, próbálja kikapcsolni. Mentés után, újraindítás nélkül érvényesül.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Minden kérés önállóan fut. Elérhetetlen szervereknél a hiba megjelenése tovább tarthat.",
"TITLE": "Beállítások",
"GENERAL": "Általános",
"GENERAL_SUBTITLE": "Az alkalmazás beállításainak módosítása",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Kérések átmenetileg szüneteltetve",
"DESCRIPTION": "A kérések ismételt kapcsolódási hibák után szünetelnek. Próbálja újra a portál azonnali eléréséhez.",
"RETRY": "Újrapróbálkozás most"
},
"ALL_CATEGORIES": "Összes kategória",
"MY_FAVORITES": "Saját kedvencek",
"ADD_TO_FAVORITES": "Hozzáadás a kedvencekhez",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Connessioni ai portali",
"PORTAL_CONNECTIVITY_GUARD": "Sospendi le richieste ai portali non disponibili",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Dopo ripetuti errori di connessione, sospendi le nuove richieste Xtream e Stalker per 30 secondi. Prova a disattivare se un portale diventa intermittentemente non disponibile. Si applica al salvataggio, senza riavvio.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Ogni richiesta verrà eseguita indipendentemente. I server non disponibili potrebbero impiegare più tempo a segnalare un errore.",
"TITLE": "Impostazioni IPTVnator",
"GENERAL": "Generale",
"GENERAL_SUBTITLE": "Modifica le impostazioni generali dell'applicazione",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Richieste temporaneamente sospese",
"DESCRIPTION": "Le richieste sono state sospese dopo ripetuti errori di connessione. Riprova per contattare subito il portale.",
"RETRY": "Riprova ora"
},
"ALL_CATEGORIES": "Tutte le categorie",
"MY_FAVORITES": "I miei preferiti",
"ADD_TO_FAVORITES": "Aggiungi ai preferiti",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "ポータル接続",
"PORTAL_CONNECTIVITY_GUARD": "利用できないポータルへのリクエストを一時停止",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "接続エラーが繰り返された場合、新しい Xtream と Stalker のリクエストを30秒間停止します。ポータルが断続的に利用できなくなる場合は、無効にしてみてください。保存後、再起動せずに適用されます。",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "各リクエストは個別に実行されます。サーバーが利用できない場合、エラーの表示に時間がかかることがあります。",
"TITLE": "設定",
"GENERAL": "一般",
"GENERAL_SUBTITLE": "アプリケーションの設定を変更",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "リクエストを一時停止中",
"DESCRIPTION": "接続エラーが繰り返されたため、リクエストを停止しました。今すぐ再試行すると、ポータルに直ちに接続します。",
"RETRY": "今すぐ再試行"
},
"ALL_CATEGORIES": "すべてのカテゴリー",
"MY_FAVORITES": "お気に入り",
"ADD_TO_FAVORITES": "お気に入りに追加",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "포털 연결",
"PORTAL_CONNECTIVITY_GUARD": "사용할 수 없는 포털에 대한 요청 일시 중지",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "연결 오류가 반복되면 새 Xtream 및 Stalker 요청을 30초 동안 중지합니다. 포털을 간헐적으로 사용할 수 없다면 이 옵션을 꺼 보세요. 저장 후 재시작 없이 적용됩니다.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "각 요청이 독립적으로 실행됩니다. 서버를 사용할 수 없으면 오류가 표시되기까지 더 오래 걸릴 수 있습니다.",
"TITLE": "설정",
"GENERAL": "일반",
"GENERAL_SUBTITLE": "애플리케이션 구성을 변경합니다",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "요청이 일시 중지됨",
"DESCRIPTION": "연결 오류가 반복되어 요청이 중지되었습니다. 지금 다시 시도하여 포털에 즉시 연결하세요.",
"RETRY": "지금 다시 시도"
},
"ALL_CATEGORIES": "모든 카테고리",
"MY_FAVORITES": "내가 좋아하는 것들",
"ADD_TO_FAVORITES": "즐겨 찾기에 추가",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Portalverbindingen",
"PORTAL_CONNECTIVITY_GUARD": "Verzoeken aan onbereikbare portals pauzeren",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Pauzeer nieuwe Xtream- en Stalker-verzoeken 30 seconden na herhaalde verbindingsfouten. Probeer dit uit te schakelen als een portal af en toe onbereikbaar is. Geldt na opslaan, zonder herstart.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Elk verzoek wordt onafhankelijk uitgevoerd. Bij onbereikbare servers kan een foutmelding langer duren.",
"TITLE": "Instellingen",
"GENERAL": "Algemeen",
"GENERAL_SUBTITLE": "Wijzig de configuratie van de applicatie",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Verzoeken tijdelijk gepauzeerd",
"DESCRIPTION": "Verzoeken zijn gepauzeerd na herhaalde verbindingsfouten. Probeer opnieuw om de portal direct te bereiken.",
"RETRY": "Nu opnieuw proberen"
},
"ALL_CATEGORIES": "Alle categorieën",
"MY_FAVORITES": "Mijn favorieten",
"ADD_TO_FAVORITES": "Voeg toe aan favorieten",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Połączenia z portalami",
"PORTAL_CONNECTIVITY_GUARD": "Wstrzymuj żądania do niedostępnych portali",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Po powtarzających się błędach połączenia wstrzymuj nowe żądania Xtream i Stalker na 30 sekund. Spróbuj wyłączyć, jeśli portal bywa niedostępny. Zmiana działa po zapisaniu, bez ponownego uruchomienia.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Każde żądanie będzie wykonywane niezależnie. Niedostępne serwery mogą zgłaszać błąd z większym opóźnieniem.",
"TITLE": "Ustawienia",
"GENERAL": "Ogólne",
"GENERAL_SUBTITLE": "Zmień konfigurację aplikacji",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Żądania tymczasowo wstrzymane",
"DESCRIPTION": "Żądania wstrzymano po powtarzających się błędach połączenia. Ponów próbę, aby natychmiast połączyć się z portalem.",
"RETRY": "Ponów teraz"
},
"ALL_CATEGORIES": "Wszystkie kategorie",
"MY_FAVORITES": "Moje ulubione",
"ADD_TO_FAVORITES": "Dodaj do ulubionych",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Ligações aos portais",
"PORTAL_CONNECTIVITY_GUARD": "Pausar pedidos a portais indisponíveis",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Após falhas repetidas de ligação, pausar novos pedidos Xtream e Stalker durante 30 segundos. Experimente desativar se um portal ficar indisponível de forma intermitente. Aplica-se ao guardar, sem reiniciar.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada pedido será executado de forma independente. Os servidores indisponíveis podem demorar mais a indicar um erro.",
"TITLE": "Configurações",
"GENERAL": "Geral",
"GENERAL_SUBTITLE": "Altere a configuração do aplicativo",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Pedidos temporariamente pausados",
"DESCRIPTION": "Os pedidos foram pausados após falhas repetidas de ligação. Tente novamente para contactar o portal imediatamente.",
"RETRY": "Tentar novamente agora"
},
"ALL_CATEGORIES": "Todas as categorias",
"MY_FAVORITES": "Meus favoritos",
"ADD_TO_FAVORITES": "Adicionar aos favoritos",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Подключение к порталам",
"PORTAL_CONNECTIVITY_GUARD": "Приостанавливать запросы к недоступным порталам",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "После повторных ошибок подключения приостанавливать новые запросы Xtream и Stalker на 30 секунд. Попробуйте отключить, если портал периодически становится недоступен. Применяется после сохранения без перезапуска.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Каждый запрос будет выполняться независимо. Если сервер недоступен, ожидание ошибки может увеличиться.",
"TITLE": "Настройки",
"GENERAL": "Общие",
"GENERAL_SUBTITLE": "Изменение основных параметров приложения",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "Запросы временно приостановлены",
"DESCRIPTION": "Запросы приостановлены после повторных ошибок подключения. Повторите попытку, чтобы обратиться к порталу сейчас.",
"RETRY": "Повторить сейчас"
},
"ALL_CATEGORIES": "Все категории",
"MY_FAVORITES": "Мои избранные",
"ADD_TO_FAVORITES": "Добавить в избранное",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "Portal bağlantıları",
"PORTAL_CONNECTIVITY_GUARD": "Kullanılamayan portallara istekleri duraklat",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tekrarlanan bağlantı hatalarından sonra yeni Xtream ve Stalker isteklerini 30 saniye duraklatır. Portal zaman zaman kullanılamıyorsa kapatmayı deneyin. Kaydettikten sonra yeniden başlatmadan uygulanır.",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "Her istek bağımsız yürütülür. Kullanılamayan sunucularda hata bildirimi daha uzun sürebilir.",
"TITLE": "Ayarlar",
"GENERAL": "Genel",
"GENERAL_SUBTITLE": "Uygulamanın yapılandırmasını değiştirin",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "İstekler geçici olarak duraklatıldı",
"DESCRIPTION": "Tekrarlanan bağlantı hatalarından sonra istekler duraklatıldı. Portala hemen bağlanmak için yeniden deneyin.",
"RETRY": "Şimdi yeniden dene"
},
"ALL_CATEGORIES": "Tüm kategoriler",
"MY_FAVORITES": "Favorilerim",
"ADD_TO_FAVORITES": "Favorilere ekle",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "门户连接",
"PORTAL_CONNECTIVITY_GUARD": "暂停向不可用门户发送请求",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "连接连续失败后,暂停新的 Xtream 和 Stalker 请求 30 秒。如果门户间歇性不可用,请尝试关闭此选项。保存后生效,无需重启。",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "每个请求将独立执行。服务器不可用时,可能需要更长时间才能显示错误。",
"TITLE": "设置",
"GENERAL": "通用",
"GENERAL_SUBTITLE": "更改应用程序的配置",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "请求已暂时暂停",
"DESCRIPTION": "连接连续失败后,请求已暂停。立即重试以重新连接门户。",
"RETRY": "立即重试"
},
"ALL_CATEGORIES": "所有分类",
"MY_FAVORITES": "我的收藏",
"ADD_TO_FAVORITES": "添加到收藏夹",
+9
View File
@@ -244,6 +244,10 @@
}
},
"SETTINGS": {
"PORTAL_CONNECTIONS": "入口網站連線",
"PORTAL_CONNECTIVITY_GUARD": "暫停向無法使用的入口網站傳送請求",
"PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "連線連續失敗後,暫停新的 Xtream 和 Stalker 請求 30 秒。如果入口網站間歇性無法使用,請嘗試關閉此選項。儲存後生效,無需重新啟動。",
"PORTAL_CONNECTIVITY_GUARD_DISABLED": "每個請求將獨立執行。伺服器無法使用時,可能需要更長時間才能顯示錯誤。",
"TITLE": "設定",
"GENERAL": "一般",
"GENERAL_SUBTITLE": "更改應用程式的配置",
@@ -1002,6 +1006,11 @@
}
},
"PORTALS": {
"REQUESTS_PAUSED": {
"TITLE": "請求已暫時暫停",
"DESCRIPTION": "連線連續失敗後,請求已暫停。立即重試以重新連線至入口網站。",
"RETRY": "立即重試"
},
"ALL_CATEGORIES": "所有類別",
"MY_FAVORITES": "我的最愛",
"ADD_TO_FAVORITES": "新增至最愛",
+2 -2
View File
@@ -48,11 +48,11 @@ const schemaPayload = jsonLd
<meta name="format-detection" content="telephone=no" />
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
<!-- Fonts: Playfair Display (display), DM Sans (body), IBM Plex Mono (code) -->
<!-- Fonts: Bricolage Grotesque (display), DM Sans (body), IBM Plex Mono (code) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap"
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
@@ -19,7 +19,7 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
<a
href={`/iptvnator/blog/${slug}/`}
class="card-dotted group block"
class="card-panel group block"
>
{heroImage && (
<div class="overflow-hidden rounded-t-lg -mx-6 -mt-6 mb-5">
@@ -40,7 +40,7 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
<>
<span class="text-surface-700">&middot;</span>
{tags.map((tag) => (
<span class="rounded-full border border-dashed border-accent-500/30 px-2 py-0.5 text-xs text-accent-400">
<span class="rounded-md border border-surface-800 bg-surface-900/60 px-2 py-0.5 text-xs text-surface-300">
{tag}
</span>
))}
@@ -28,7 +28,7 @@ const packageManagers = [
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 <em class="font-medium text-accent-400" style="font-style: italic;">IPTVnator</em>
Get IPTVnator
</h2>
<p class="mt-4 text-lg text-surface-400">
Download for your platform or use it directly in the browser.
@@ -41,7 +41,7 @@ const packageManagers = [
platforms.map((platform) => (
<a
href={platform.href}
class="card-dotted group flex items-center gap-4 px-8 py-5"
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} />
@@ -59,7 +59,7 @@ const packageManagers = [
<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-dashed border-surface-700/60 bg-surface-900/50 px-5 py-3.5">
<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>
+8 -11
View File
@@ -84,7 +84,7 @@ const carouselFeatures: FeatureCard[] = [
Features
</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
Everything <em class="font-medium text-accent-400" style="font-style: italic;">you need</em>
Everything you need
</h2>
<p class="mt-4 text-lg text-surface-400">
A full-featured IPTV player packed with the tools for the best viewing experience.
@@ -95,7 +95,7 @@ const carouselFeatures: FeatureCard[] = [
<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-dashed 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 hover:shadow-[0_0_30px_rgba(32,168,168,0.08)]">
<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'}`}>
@@ -106,9 +106,6 @@ const carouselFeatures: FeatureCard[] = [
<!-- 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" />
<!-- CRT scanline overlay on hover -->
<div class="pointer-events-none absolute inset-0 z-10 opacity-0 transition-opacity duration-500 group-hover:opacity-100" style="background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(32,168,168,0.015) 2px, rgba(32,168,168,0.015) 4px);" />
<!-- Card content -->
<div class="relative z-[1]">
<!-- Mono label + accent line -->
@@ -148,10 +145,10 @@ const carouselFeatures: FeatureCard[] = [
<!-- 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-dashed 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 hover:shadow-[0_0_24px_rgba(32,168,168,0.06)]">
<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 group-hover:shadow-[0_0_14px_rgba(32,168,168,0.12)]">
<svg class="h-5 w-5 text-accent-400 transition-all duration-500 group-hover:text-accent-300 group-hover:drop-shadow-[0_0_4px_rgba(32,168,168,0.4)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
@@ -167,10 +164,10 @@ const carouselFeatures: FeatureCard[] = [
<!-- Duplicate set for seamless loop -->
{
carouselFeatures.map((feature, i) => (
<div class="group relative w-64 shrink-0 overflow-hidden rounded-lg border border-dashed 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 hover:shadow-[0_0_24px_rgba(32,168,168,0.06)]" aria-hidden="true">
<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 group-hover:shadow-[0_0_14px_rgba(32,168,168,0.12)]">
<svg class="h-5 w-5 text-accent-400 transition-all duration-500 group-hover:text-accent-300 group-hover:drop-shadow-[0_0_4px_rgba(32,168,168,0.4)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<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>
@@ -19,7 +19,7 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
<a
href={`/iptvnator/blog/${slug}/`}
class="card-dotted group block overflow-hidden"
class="card-panel group block overflow-hidden"
>
<div class="grid items-center gap-8 md:grid-cols-2">
<!-- Thumbnail -->
@@ -55,7 +55,7 @@ const formattedDate = pubDate.toLocaleDateString('en-US', {
{tags.length > 0 && (
<div class="mt-2 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span class="rounded-full border border-dashed border-accent-500/30 px-2 py-0.5 text-xs text-accent-400">
<span class="rounded-md border border-surface-800 bg-surface-900/60 px-2 py-0.5 text-xs text-surface-300">
{tag}
</span>
))}
+2 -2
View File
@@ -25,7 +25,7 @@ const socialLinks = [
];
---
<footer class="relative overflow-hidden border-t border-dashed border-surface-700/60">
<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]">
@@ -76,7 +76,7 @@ const socialLinks = [
</div>
</div>
<div class="mt-10 flex flex-col items-center justify-between gap-4 border-t border-dashed border-surface-700/60 pt-8 md:flex-row">
<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>
@@ -1,4 +1,4 @@
<section class="mt-16 border-t border-dashed border-surface-700/60 pt-10" aria-labelledby="blog-comments-heading">
<section class="mt-16 border-t border-surface-700/60 pt-10" aria-labelledby="blog-comments-heading">
<div class="mb-6">
<h2 id="blog-comments-heading" class="font-display text-2xl font-semibold tracking-tight text-surface-100">
Comments
+3 -3
View File
@@ -56,7 +56,7 @@ const navLinks = [
</a>
<a
href="https://github.com/4gray/iptvnator/releases/latest"
class="rounded-lg border border-accent-500/30 bg-accent-500/10 px-4 py-2 text-[13px] font-medium text-accent-400 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/20 hover:text-accent-300"
class="rounded-lg bg-accent-500 px-4 py-2 text-[13px] font-semibold text-surface-950 transition-colors duration-200 hover:bg-accent-400"
>
Download
</a>
@@ -80,7 +80,7 @@ const navLinks = [
<!-- Mobile menu -->
<div
id="mobile-menu"
class="hidden border-t border-dashed border-surface-700/60 bg-surface-950/98 backdrop-blur-2xl md:hidden"
class="hidden border-t border-surface-700/60 bg-surface-950/98 backdrop-blur-2xl md:hidden"
>
<div class="space-y-1 px-6 py-5">
{
@@ -107,7 +107,7 @@ const navLinks = [
</a>
<a
href="https://github.com/4gray/iptvnator/releases/latest"
class="flex-1 rounded-lg border border-accent-500/30 bg-accent-500/10 px-3 py-2.5 text-center text-[13px] font-medium text-accent-400 transition-all hover:border-accent-400/50 hover:bg-accent-500/20"
class="flex-1 rounded-lg bg-accent-500 px-3 py-2.5 text-center text-[13px] font-semibold text-surface-950 transition-colors hover:bg-accent-400"
>
Download
</a>
+15 -36
View File
@@ -40,14 +40,14 @@ const heroSocialLinks = [
</div>
<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 blur-[0.2px] md:block lg:w-[22rem] xl:w-[25rem]">
<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 animate-glow-pulse"
class="relative w-full object-contain saturate-110"
/>
</div>
@@ -60,9 +60,9 @@ const heroSocialLinks = [
<span class="h-px w-8 bg-accent-500/50"></span>
</div>
<!-- Headline with serif italic -->
<!-- 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, <em class="font-medium not-italic text-accent-400" style="font-style: italic;">your way.</em>
Watch, <span class="text-accent-400">your way.</span>
</h1>
<p class="mx-auto mt-8 max-w-xl text-lg leading-relaxed text-surface-400">
@@ -70,42 +70,25 @@ const heroSocialLinks = [
Stalker portals. Desktop app and Progressive Web App.
</p>
<!-- CTA: Big TV-effect download button -->
<div class="mt-12 flex flex-col items-center gap-6">
<!-- CTA -->
<div class="mt-12 flex flex-col items-center gap-5">
<a
href="https://github.com/4gray/iptvnator/releases/latest"
class="btn-tv group rounded-2xl px-10 py-6 sm:px-14 sm:py-7"
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"
>
<!-- TV effect layers -->
<span class="tv-scanline"></span>
<span class="tv-noise"></span>
<span class="tv-vignette"></span>
<!-- Button content -->
<span class="relative z-10 flex items-center gap-4">
<svg class="h-6 w-6 text-accent-400 transition-transform duration-300 group-hover:translate-y-0.5 sm:h-7 sm:w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span class="flex flex-col items-start">
<span class="btn-tv-text font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
Download <em class="font-medium text-accent-400" style="font-style: italic;">for Desktop</em>
</span>
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-surface-500">
macOS · Windows · Linux
</span>
</span>
</span>
<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.5 rounded-lg border border-dashed border-surface-600/60 px-7 py-3.5 text-sm font-medium text-surface-400 transition-all duration-300 hover:border-accent-500/30 hover:text-accent-400"
class="group inline-flex items-center gap-2 text-sm font-medium text-surface-400 transition-colors duration-200 hover:text-surface-100"
>
<svg class="h-4 w-4 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Or self-host the Web App
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>
@@ -140,7 +123,7 @@ const heroSocialLinks = [
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center gap-2 rounded-full border border-dashed 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"
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}
>
@@ -160,10 +143,6 @@ const heroSocialLinks = [
<!-- Framed screenshot -->
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-2xl shadow-black/40">
<!-- Scanline overlay -->
<div class="pointer-events-none absolute inset-0 z-10 overflow-hidden opacity-[0.03]">
<div class="h-full w-full animate-scan" style="background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.5) 2px, rgba(255,255,255,0.5) 4px);"></div>
</div>
<img
src="/iptvnator/screenshots/screenshot-player.webp"
alt="IPTVnator desktop app: channel list, live player, and EPG guide in the dark theme"
@@ -53,7 +53,7 @@ const tabs = [
Interface
</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
See it <em class="font-medium text-accent-400" style="font-style: italic;">in action</em>
See it in action
</h2>
<p class="mt-4 text-lg text-surface-400">
A clean, intuitive interface designed for the best viewing experience.
@@ -62,7 +62,7 @@ const tabs = [
<div class="mt-14">
<!-- Tab buttons with underline style -->
<div class="flex flex-wrap justify-center gap-1 rounded-lg border border-dashed border-surface-700/60 bg-surface-900/50 p-1.5" role="tablist">
<div class="flex flex-wrap justify-center gap-1 rounded-lg border border-surface-700/60 bg-surface-900/50 p-1.5" role="tablist">
{
tabs.map((tab, i) => (
<button
@@ -99,7 +99,7 @@ const tabs = [
<!-- Window chrome -->
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-2xl shadow-black/30">
<div class="flex items-center gap-2 border-b border-dashed border-surface-700/60 px-4 py-2.5">
<div class="flex items-center gap-2 border-b border-surface-700/60 px-4 py-2.5">
<div class="flex gap-1.5">
<span class="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<span class="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
@@ -28,7 +28,7 @@ const supportLinks = [
<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-dotted relative overflow-hidden rounded-2xl bg-surface-900/35">
<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"
@@ -41,7 +41,7 @@ const supportLinks = [
<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 <em class="font-medium text-accent-400" style="font-style: italic;">open source</em>
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
@@ -54,7 +54,7 @@ const supportLinks = [
href={item.href}
target="_blank"
rel="noopener noreferrer"
class="group flex h-full flex-col rounded-xl border border-dashed 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="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]"
>
<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">
@@ -70,7 +70,7 @@ const supportLinks = [
</div>
</div>
<aside class="card-dotted rounded-2xl bg-surface-900/25">
<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,
+1 -1
View File
@@ -27,7 +27,7 @@ const styles = {
const variant = styles[type];
---
<div class={`not-prose my-6 rounded-xl border border-dashed p-4 sm:p-5 ${variant.wrapper}`}>
<div class={`not-prose my-6 rounded-xl border p-4 sm:p-5 ${variant.wrapper}`}>
<div class="flex items-start gap-3">
<span class={`mt-0.5 ${variant.icon}`} aria-hidden="true">
<svg class="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
@@ -21,7 +21,7 @@ const sliderId = `slider-${Math.random().toString(36).slice(2, 10)}`;
data-blog-slider={sliderId}
tabindex="0"
>
<div class="group relative overflow-hidden rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50">
<div class="group relative overflow-hidden rounded-xl border border-surface-700/70 bg-surface-900/50">
<img
src={images[0].src}
alt={images[0].alt}
@@ -12,10 +12,10 @@ interface Props {
const { rows = [] } = Astro.props;
---
<div class="not-prose my-6 overflow-x-auto rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50">
<div class="not-prose my-6 overflow-x-auto rounded-xl border border-surface-700/70 bg-surface-900/50">
<table class="w-full min-w-[560px] border-collapse text-left">
<thead>
<tr class="border-b border-dashed border-surface-700/60">
<tr class="border-b border-surface-700/60">
<th class="px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Area</th>
<th class="px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Change</th>
<th class="px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Impact</th>
@@ -23,7 +23,7 @@ const { rows = [] } = Astro.props;
</thead>
<tbody>
{rows.map((row) => (
<tr class="border-b border-dashed border-surface-700/40 last:border-b-0">
<tr class="border-b border-surface-700/40 last:border-b-0">
<td class="px-4 py-3 text-sm font-semibold text-surface-200">{row.area}</td>
<td class="px-4 py-3 text-sm text-surface-300">{row.change}</td>
<td class="px-4 py-3 text-sm text-surface-400">{row.impact ?? '—'}</td>
@@ -7,10 +7,10 @@ interface Props {
const { command, title = 'Command' } = Astro.props;
---
<div class="not-prose my-6 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/55 p-3">
<div class="not-prose my-6 rounded-xl border border-surface-700/70 bg-surface-900/55 p-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">{title}</span>
<button class="copy-command-btn rounded-md border border-dashed border-surface-700/70 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.08em] text-surface-400 transition-colors hover:text-accent-300" data-command={command}>
<button class="copy-command-btn rounded-md border border-surface-700/70 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.08em] text-surface-400 transition-colors hover:text-accent-300" data-command={command}>
Copy
</button>
</div>
@@ -13,14 +13,14 @@ const { items = [] } = Astro.props;
<div class="not-prose my-6 space-y-2">
{items.map((item) => (
<details class="group rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45 px-4 py-3">
<details class="group rounded-xl border border-surface-700/70 bg-surface-900/45 px-4 py-3">
<summary class="cursor-pointer list-none">
<span class="flex items-center justify-between gap-3 text-sm font-semibold text-surface-100">
<span>{item.q}</span>
<span class="text-surface-500 transition-transform duration-200 group-open:rotate-45">+</span>
</span>
</summary>
<p class="mt-2 border-t border-dashed border-surface-700/60 pt-2 text-sm leading-relaxed text-surface-400">{item.a}</p>
<p class="mt-2 border-t border-surface-700/60 pt-2 text-sm leading-relaxed text-surface-400">{item.a}</p>
</details>
))}
</div>
@@ -22,7 +22,7 @@ const iconPaths = {
<div class="not-prose my-6 grid gap-3 sm:grid-cols-2">
{items.map((item) => (
<div class="rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45 p-4">
<div class="rounded-xl border border-surface-700/70 bg-surface-900/45 p-4">
<div class="mb-2.5 flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md border border-accent-500/25 bg-accent-500/[0.1]">
<svg class="h-3.5 w-3.5 text-accent-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7">
@@ -22,7 +22,7 @@ const colClass = {
<div class="not-prose my-6">
<div class={`grid gap-3 ${colClass[columns]}`}>
{images.map((img, idx) => (
<figure class="group overflow-hidden rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45">
<figure class="group overflow-hidden rounded-xl border border-surface-700/70 bg-surface-900/45">
<button
type="button"
class="gallery-open block w-full"
@@ -31,7 +31,7 @@ const iconPaths = {
href={item.href}
target={item.href.startsWith('/') ? undefined : '_blank'}
rel={item.href.startsWith('/') ? undefined : 'noopener noreferrer'}
class="group rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
class="group rounded-xl border border-surface-700/70 bg-surface-900/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
>
<div class="flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md border border-accent-500/25 bg-accent-500/[0.09]">
@@ -12,7 +12,7 @@ const { href, label, external = true } = Astro.props;
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="not-prose group my-6 inline-flex items-center gap-2 rounded-lg border border-dashed border-accent-500/35 bg-accent-500/[0.08] px-4 py-2.5 text-sm font-medium text-accent-200 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/[0.14] hover:text-accent-100"
class="not-prose group my-6 inline-flex items-center gap-2 rounded-lg border border-accent-500/35 bg-accent-500/[0.08] px-4 py-2.5 text-sm font-medium text-accent-200 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/[0.14] hover:text-accent-100"
>
<span>{label ?? 'Open link'}</span>
<svg class="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
@@ -8,7 +8,7 @@ interface Props {
const { version, releaseDate, channels = [] } = Astro.props;
---
<div class="not-prose my-6 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 p-4 sm:p-5">
<div class="not-prose my-6 rounded-xl border border-surface-700/70 bg-surface-900/50 p-4 sm:p-5">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<p class="m-0 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Version</p>
@@ -7,7 +7,7 @@ interface Props {
const { title = 'Show spoiler', open = false } = Astro.props;
---
<details class="not-prose my-5 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 px-4 py-3" open={open}>
<details class="not-prose my-5 rounded-xl border border-surface-700/70 bg-surface-900/50 px-4 py-3" open={open}>
<summary class="cursor-pointer list-none font-mono text-xs uppercase tracking-[0.1em] text-accent-300">
<span class="inline-flex items-center gap-2">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
@@ -16,7 +16,7 @@ const { title = 'Show spoiler', open = false } = Astro.props;
{title}
</span>
</summary>
<div class="mt-3 border-t border-dashed border-surface-700/60 pt-3 text-sm leading-relaxed text-surface-300 [&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:my-1.5 [&_li]:marker:text-surface-600 [&_a]:text-accent-400 [&_a:hover]:underline [&_strong]:font-semibold [&_strong]:text-surface-100 [&_code]:rounded [&_code]:bg-surface-800/50 [&_code]:px-1 [&_code]:font-mono [&_code]:text-accent-300">
<div class="mt-3 border-t border-surface-700/60 pt-3 text-sm leading-relaxed text-surface-300 [&_p]:my-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:my-1.5 [&_li]:marker:text-surface-600 [&_a]:text-accent-400 [&_a:hover]:underline [&_strong]:font-semibold [&_strong]:text-surface-100 [&_code]:rounded [&_code]:bg-surface-800/50 [&_code]:px-1 [&_code]:font-mono [&_code]:text-accent-300">
<slot />
</div>
</details>
@@ -15,6 +15,6 @@ const styles = {
} as const;
---
<span class={`not-prose inline-flex items-center rounded-full border border-dashed px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] ${styles[type]}`}>
<span class={`not-prose inline-flex items-center rounded-full border px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] ${styles[type]}`}>
{label ?? type}
</span>
@@ -7,12 +7,12 @@ interface Props {
const { steps = [], title } = Astro.props;
---
<div class="not-prose my-6 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45 p-4 sm:p-5">
<div class="not-prose my-6 rounded-xl border border-surface-700/70 bg-surface-900/45 p-4 sm:p-5">
{title && <p class="mb-3 font-mono text-[11px] uppercase tracking-[0.12em] text-accent-400">{title}</p>}
<ol class="m-0 list-none space-y-3 p-0">
{steps.map((step, index) => (
<li class="flex items-start gap-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500/35 bg-accent-500/[0.1] font-mono text-[11px] font-semibold text-accent-200">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-accent-500/35 bg-accent-500/[0.1] font-mono text-[11px] font-semibold text-accent-200">
{index + 1}
</span>
<p class="m-0 pt-0.5 text-sm leading-relaxed text-surface-200">{step}</p>
@@ -6,8 +6,8 @@ interface Props {
const { title = 'Terminal' } = Astro.props;
---
<div class="not-prose my-6 overflow-hidden rounded-xl border border-dashed border-surface-700/70 bg-surface-900/60">
<div class="flex items-center justify-between border-b border-dashed border-surface-700/60 px-4 py-2.5">
<div class="not-prose my-6 overflow-hidden rounded-xl border border-surface-700/70 bg-surface-900/60">
<div class="flex items-center justify-between border-b border-surface-700/60 px-4 py-2.5">
<div class="flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full bg-red-500/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-yellow-500/70"></span>
@@ -32,12 +32,12 @@ const { comparison, titleLead, titleAccent, lede, verdict } = Astro.props;
<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>
<span class="text-accent-400">{titleAccent}</span>
</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">
<div class="mt-10 rounded-xl border 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>
@@ -19,7 +19,7 @@ const others = COMPARISONS.filter((comparison) => comparison.slug !== current);
<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>
deciding?
</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>
@@ -27,7 +27,7 @@ const others = COMPARISONS.filter((comparison) => comparison.slug !== current);
<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">
<a href={comparison.href} class="card-panel 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} />
@@ -24,10 +24,10 @@ 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">
<div class="overflow-x-auto rounded-lg border 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">
<tr class="border-b 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>
@@ -36,7 +36,7 @@ const { columns, rowHeader = 'Capability', rows, caption } = Astro.props;
</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">
<tr class="border-b 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>}
@@ -14,7 +14,7 @@ const detail = size ? `${download.detail} · ${size}` : download.detail;
const baseClass = 'group inline-flex items-center gap-4 rounded-xl px-6 py-4 transition-all duration-300';
const variantClass = primary
? 'bg-accent-500 text-surface-950 shadow-[0_0_30px_rgba(32,168,168,0.25)] hover:-translate-y-0.5 hover:bg-accent-400 hover:shadow-[0_0_44px_rgba(32,168,168,0.4)]'
: 'border border-dashed border-surface-600/60 bg-surface-900/50 text-surface-100 hover:border-accent-500/40 hover:bg-accent-500/[0.05]';
: 'border border-surface-600/60 bg-surface-900/50 text-surface-100 hover:border-accent-500/40 hover:bg-accent-500/[0.05]';
const detailClass = primary ? 'text-surface-950/70' : 'text-surface-500';
---
@@ -55,7 +55,7 @@ const releaseDate = formatReleaseDate(release.publishedAt);
<h1 class="mt-6 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>
<span class="text-accent-400">{titleAccent}</span>
</h1>
<p class="mt-6 max-w-xl text-lg leading-relaxed text-surface-400">{lede}</p>
@@ -82,14 +82,14 @@ const releaseDate = formatReleaseDate(release.publishedAt);
<ul class="mt-8 flex flex-wrap gap-2" aria-label="Key facts">
{facts.map((fact) => (
<li class="rounded-full border border-dashed border-surface-700/70 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-400">{fact}</li>
<li class="rounded-full border border-surface-700/70 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-400">{fact}</li>
))}
</ul>
</div>
<figure class="relative">
<div class="pointer-events-none absolute -inset-6 rounded-3xl bg-accent-500/[0.05] blur-2xl"></div>
<div class="relative overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.45)]">
<div class="relative overflow-hidden rounded-xl border border-surface-600/60 bg-surface-900/60 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.45)]">
<img
src={screenshot.src}
alt={screenshot.alt}
@@ -21,7 +21,7 @@ const { id, eyebrow, title, accent, lead } = Astro.props;
{accent && (
<Fragment>
{' '}
<em class="font-medium text-accent-400" style="font-style: italic;">{accent}</em>
{accent}
</Fragment>
)}
</h2>
@@ -13,7 +13,7 @@ const { items } = Astro.props;
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => (
<div class="group relative overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-gradient-to-b from-surface-900/60 to-surface-950/40 p-6 transition-all duration-500 hover:border-accent-500/40 hover:shadow-[0_0_24px_rgba(32,168,168,0.06)]">
<div class="group relative overflow-hidden rounded-lg border border-surface-600/60 bg-gradient-to-b from-surface-900/60 to-surface-950/40 p-6 transition-all duration-500 hover:border-accent-500/40 hover:shadow-[0_0_24px_rgba(32,168,168,0.06)]">
<div class="mb-4 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">
{String(index + 1).padStart(2, '0')}
@@ -2,7 +2,7 @@
import { LATEST_RELEASE_URL } from '../../lib/downloads';
---
<div class="rounded-xl border border-dashed border-amber-500/35 bg-amber-500/[0.07] p-5 sm:p-6">
<div class="rounded-xl border border-amber-500/35 bg-amber-500/[0.07] p-5 sm:p-6">
<div class="flex items-start gap-3">
<span class="mt-0.5 text-amber-300" aria-hidden="true">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
@@ -17,11 +17,11 @@ interface Props {
const { title, rows } = Astro.props;
---
<div class="overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-surface-900/40">
<div class="border-b border-dashed border-surface-700/60 px-5 py-3">
<div class="overflow-hidden rounded-lg border border-surface-600/60 bg-surface-900/40">
<div class="border-b border-surface-700/60 px-5 py-3">
<span class="font-mono text-[11px] uppercase tracking-[0.15em] text-accent-400">{title}</span>
</div>
<ul class="divide-y divide-dashed divide-surface-700/60">
<ul class="divide-y divide-surface-700/60">
{rows.map(({ download, command, note }) => {
const size = formatAssetSize(download.size);
return (
@@ -19,20 +19,20 @@ const others = DOWNLOAD_PLATFORMS.filter((platform) => platform !== current);
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Other platforms</span>
<h2 class="mt-3 font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
Also available for{' '}
<em class="font-medium text-accent-400" style="font-style: italic;">{others.map((platform) => PLATFORM_LABELS[platform]).join(' and ')}</em>
{others.map((platform) => PLATFORM_LABELS[platform]).join(' and ')}
</h2>
</div>
<div class="flex flex-wrap gap-3">
{others.map((platform) => (
<a href={PLATFORM_PAGES[platform]} class="card-dotted group flex items-center gap-3 px-5 py-3">
<a href={PLATFORM_PAGES[platform]} class="card-panel group flex items-center gap-3 px-5 py-3">
<svg class="h-5 w-5 text-surface-500 transition-colors duration-200 group-hover:text-accent-400" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d={PLATFORM_ICON_PATHS[platform]} />
</svg>
<span class="text-sm font-semibold text-surface-100">IPTVnator for {PLATFORM_LABELS[platform]}</span>
</a>
))}
<a href="/iptvnator/download/" class="card-dotted flex items-center px-5 py-3 text-sm font-semibold text-surface-300 hover:text-accent-400">
<a href="/iptvnator/download/" class="card-panel flex items-center px-5 py-3 text-sm font-semibold text-surface-300 hover:text-accent-400">
All downloads
</a>
</div>
@@ -11,7 +11,7 @@ interface Props {
const { items } = Astro.props;
---
<dl class="grid gap-px overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-surface-700/40 sm:grid-cols-2">
<dl class="grid gap-px overflow-hidden rounded-lg border border-surface-600/60 bg-surface-700/40 sm:grid-cols-2">
{items.map((item) => (
<div class="bg-surface-950/95 px-5 py-4">
<dt class="font-mono text-[10px] uppercase tracking-[0.15em] text-surface-500">{item.term}</dt>
@@ -48,7 +48,7 @@ const { feature, titleLead, titleAccent, lede, facts, screenshot } = Astro.props
<h1 class="mt-6 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>
<span class="text-accent-400">{titleAccent}</span>
</h1>
<p class="mt-6 max-w-xl text-lg leading-relaxed text-surface-400">{lede}</p>
@@ -65,7 +65,7 @@ const { feature, titleLead, titleAccent, lede, facts, screenshot } = Astro.props
</a>
<a
href={feature.guide.href}
class="inline-flex items-center gap-3 rounded-xl border border-dashed border-surface-600/60 bg-surface-900/50 px-6 py-4 text-base font-semibold text-surface-100 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.05]"
class="inline-flex items-center gap-3 rounded-xl border border-surface-600/60 bg-surface-900/50 px-6 py-4 text-base font-semibold text-surface-100 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.05]"
>
<svg class="h-5 w-5 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.483 9.246 5 7.5 5 5.205 5 3.182 5.836 2 7.117v11.41C3.182 17.247 5.205 16.41 7.5 16.41c1.746 0 3.332.484 4.5 1.254m0-11.41C13.168 5.483 14.754 5 16.5 5c2.295 0 4.318.836 5.5 2.117v11.41C20.818 17.247 18.795 16.41 16.5 16.41c-1.746 0-3.332.484-4.5 1.254" />
@@ -76,14 +76,14 @@ const { feature, titleLead, titleAccent, lede, facts, screenshot } = Astro.props
<ul class="mt-8 flex flex-wrap gap-2" aria-label="Key facts">
{facts.map((fact) => (
<li class="rounded-full border border-dashed border-surface-700/70 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-400">{fact}</li>
<li class="rounded-full border border-surface-700/70 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-400">{fact}</li>
))}
</ul>
</div>
<figure class="relative">
<div class="pointer-events-none absolute -inset-6 rounded-3xl bg-accent-500/[0.05] blur-2xl"></div>
<div class="relative overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.45)]">
<div class="relative overflow-hidden rounded-xl border border-surface-600/60 bg-surface-900/60 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.45)]">
<img
src={screenshot.src}
alt={screenshot.alt}
@@ -19,7 +19,7 @@ const others = FEATURES.filter((feature) => feature.slug !== current);
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">More features</span>
<h2 class="mt-3 font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
The rest of{' '}
<em class="font-medium text-accent-400" style="font-style: italic;">IPTVnator</em>
IPTVnator
</h2>
</div>
<a href={FEATURES_HUB_HREF} class="font-mono text-xs uppercase tracking-[0.1em] text-accent-500 transition-colors hover:text-accent-400">All features</a>
@@ -27,7 +27,7 @@ const others = FEATURES.filter((feature) => feature.slug !== current);
<div class="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{others.map((feature) => (
<a href={feature.href} class="card-dotted group flex flex-col gap-3 p-5">
<a href={feature.href} class="card-panel group flex flex-col gap-3 p-5">
<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={feature.icon} />
+3 -3
View File
@@ -119,7 +119,7 @@ const jsonLd = faqSchema ? [blogPostSchema, faqSchema] : blogPostSchema;
<>
<span class="text-surface-700">&middot;</span>
{tags.map((tag) => (
<span class="rounded-full border border-dashed border-accent-500/30 px-2.5 py-0.5 text-xs font-medium text-accent-400">
<span class="rounded-md border border-surface-800 bg-surface-900/60 px-2.5 py-0.5 text-xs font-medium text-surface-300">
{tag}
</span>
))}
@@ -138,7 +138,7 @@ const jsonLd = faqSchema ? [blogPostSchema, faqSchema] : blogPostSchema;
<img
src={AUTHOR_AVATAR_URL}
alt={`${author} avatar`}
class="h-8 w-8 rounded-full border border-dashed border-accent-500/30 object-cover"
class="h-8 w-8 rounded-full border border-surface-700 object-cover"
loading="lazy"
/>
<div>
@@ -168,7 +168,7 @@ const jsonLd = faqSchema ? [blogPostSchema, faqSchema] : blogPostSchema;
<!-- Content -->
<div
class="prose prose-invert mt-12 max-w-none prose-headings:font-display prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-surface-100 prose-p:text-surface-300 prose-a:text-accent-400 prose-a:no-underline prose-a:decoration-accent-500/30 hover:prose-a:underline prose-strong:text-surface-100 prose-code:rounded prose-code:bg-surface-800/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:text-accent-300 prose-code:before:content-[''] prose-code:after:content-[''] prose-pre:border prose-pre:border-dashed prose-pre:border-surface-700/60 prose-pre:bg-surface-900/50 prose-blockquote:border-accent-500/30 prose-blockquote:text-surface-400 prose-hr:border-dashed prose-hr:border-surface-700/60"
class="prose prose-invert mt-12 max-w-none prose-headings:font-display prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-surface-100 prose-p:text-surface-300 prose-a:text-accent-400 prose-a:no-underline prose-a:decoration-accent-500/30 hover:prose-a:underline prose-strong:text-surface-100 prose-code:rounded prose-code:bg-surface-800/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:text-accent-300 prose-code:before:content-[''] prose-code:after:content-[''] prose-pre:border prose-pre:border-surface-700/60 prose-pre:bg-surface-900/50 prose-blockquote:border-accent-500/30 prose-blockquote:text-surface-400 prose-hr:border-surface-700/60"
>
<slot />
</div>
+4 -4
View File
@@ -48,7 +48,7 @@ const jsonLd = [
<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>
Which option is <span class="text-accent-400">right for you</span>
</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
@@ -58,7 +58,7 @@ const jsonLd = [
<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">
<a href={comparison.href} class="card-panel 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} />
@@ -84,13 +84,13 @@ const jsonLd = [
<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>
Read about a single feature
</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">
<a href={feature.href} class="card-panel 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} />
+3 -3
View File
@@ -74,7 +74,7 @@ const jsonLd = [
<div class="mx-auto max-w-2xl text-center">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Download</span>
<h1 class="mt-4 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
Get <em class="font-medium text-accent-400" style="font-style: italic;">IPTVnator</em>
Get <span class="text-accent-400">IPTVnator</span>
</h1>
<p class="mt-6 text-lg leading-relaxed text-surface-400">
Pick your operating system for a direct download, install steps and the answers to the usual questions.
@@ -90,7 +90,7 @@ const jsonLd = [
<div class="mt-14 grid gap-5 md:grid-cols-3">
{cards.map((card) => (
<a href={card.href} class="card-dotted group flex flex-col p-7">
<a href={card.href} class="card-panel group flex flex-col p-7">
<div class="flex items-center gap-3">
<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="currentColor" viewBox="0 0 24 24" aria-hidden="true">
@@ -125,7 +125,7 @@ const jsonLd = [
<div>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Package managers</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
One command <em class="font-medium text-accent-400" style="font-style: italic;">instead</em>
One command instead
</h2>
<p class="mt-4 text-lg text-surface-400">
Homebrew, the Snap Store and the AUR track every release. The self-hosted browser version runs from a
@@ -205,7 +205,7 @@ const jsonLd = buildDownloadPageSchema({
It is opt-in and can be switched off again at any time.
</p>
</div>
<figure class="overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2">
<figure class="overflow-hidden rounded-xl border border-surface-600/60 bg-surface-900/60 p-2">
<img
src="/iptvnator/screenshots/external-player-support-mpv.webp"
alt="Playback settings with MPV selected as the external player"
+1 -1
View File
@@ -132,7 +132,7 @@ const jsonLd = buildFeaturePageSchema({
<DownloadSection id="sources" eyebrow="Sources" title="Where the guide" accent="comes from">
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] lg:items-start">
<SpecList items={sources} />
<figure class="overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2">
<figure class="overflow-hidden rounded-xl border border-surface-600/60 bg-surface-900/60 p-2">
<img
src="/iptvnator/blog/guides/screenshots/guide-epg-settings-dark.png"
alt="Settings with the EPG section open, a source URL entered and its status chip"
+5 -5
View File
@@ -61,7 +61,7 @@ const jsonLd = [
<div class="mx-auto max-w-2xl text-center">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Features</span>
<h1 class="mt-4 font-display text-4xl font-bold tracking-tight text-surface-50 sm:text-5xl lg:text-6xl">
Everything <em class="font-medium text-accent-400" style="font-style: italic;">IPTVnator</em> does
Everything <span class="text-accent-400">IPTVnator</span> does
</h1>
<p class="mt-6 text-lg leading-relaxed text-surface-400">
One player for the three ways providers hand out access, with a program guide, a phone remote and the
@@ -77,7 +77,7 @@ const jsonLd = [
<div class="mt-14 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((feature) => (
<a href={feature.href} class="card-dotted group flex flex-col p-7">
<a href={feature.href} class="card-panel 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={feature.icon} />
@@ -93,7 +93,7 @@ const jsonLd = [
</span>
</a>
))}
<a href="/iptvnator/download/" class="group flex flex-col justify-between rounded-lg border border-dashed border-accent-500/40 bg-accent-500/[0.06] p-7 transition-all duration-300 hover:bg-accent-500/[0.1]">
<a href="/iptvnator/download/" class="group flex flex-col justify-between rounded-lg border border-accent-500/40 bg-accent-500/[0.06] p-7 transition-all duration-300 hover:bg-accent-500/[0.1]">
<div>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Get started</span>
<h2 class="mt-4 font-display text-xl font-bold tracking-tight text-surface-50">Download for Windows, macOS or Linux</h2>
@@ -116,10 +116,10 @@ const jsonLd = [
<div class="max-w-2xl">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">And more</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
The rest of the <em class="font-medium text-accent-400" style="font-style: italic;">desktop app</em>
The rest of the desktop app
</h2>
</div>
<dl class="mt-12 grid gap-px overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-surface-700/40 sm:grid-cols-2">
<dl class="mt-12 grid gap-px overflow-hidden rounded-lg border border-surface-600/60 bg-surface-700/40 sm:grid-cols-2">
{more.map((item) => (
<div class="bg-surface-950/95 px-5 py-4">
<dt class="font-display text-base font-semibold text-surface-100">{item.title}</dt>
@@ -157,7 +157,7 @@ const jsonLd = buildFeaturePageSchema({
repairs the stored connection.
</p>
</div>
<figure class="overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2">
<figure class="overflow-hidden rounded-xl border border-surface-600/60 bg-surface-900/60 p-2">
<img
src="/iptvnator/blog/guides/screenshots/guide-stalker-add-playlist-dark.png"
alt="Add playlist dialog with the Stalker portal method, a portal URL and a MAC address"
@@ -158,7 +158,7 @@ const jsonLd = buildFeaturePageSchema({
episode by episode for offline viewing.
</p>
</div>
<figure class="overflow-hidden rounded-xl border border-dashed border-surface-600/60 bg-surface-900/60 p-2">
<figure class="overflow-hidden rounded-xl border border-surface-600/60 bg-surface-900/60 p-2">
<img
src="/iptvnator/blog/v0-23/screenshots/xtream-series-season-open-dark.png"
alt="Series details with season tabs and the episode list of a season"
+1 -1
View File
@@ -81,7 +81,7 @@ const homeSchema = [
Blog
</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
Latest <em class="font-medium text-accent-400" style="font-style: italic;">updates</em>
Latest updates
</h2>
<p class="mt-4 text-lg text-surface-400">
Release notes, tutorials, and project updates.
+7 -363
View File
@@ -34,7 +34,7 @@
@layer components {
.section-divider {
border: none;
border-top: 1px dashed theme('colors.surface.700');
border-top: 1px solid theme('colors.surface.800');
@apply mx-auto max-w-5xl;
}
@@ -58,358 +58,13 @@
.stagger-children.stagger-visible > *:nth-child(11) { animation-delay: 800ms; }
.stagger-children.stagger-visible > *:nth-child(12) { animation-delay: 880ms; }
.card-dotted {
@apply border border-dashed border-surface-600/60 rounded-lg p-6 transition-all duration-300;
/* Elevated panel: a bg step plus a hairline, never a dashed outline. */
.card-panel {
@apply rounded-xl border border-surface-800 bg-surface-900/40 p-6 transition-colors duration-300;
}
.card-dotted:hover {
@apply border-accent-500/40 bg-accent-500/[0.03];
}
/* ── TV-effect download button ── */
.btn-tv {
position: relative;
display: inline-flex;
overflow: hidden;
isolation: isolate;
border: 1px solid rgba(32, 168, 168, 0.3);
background: linear-gradient(135deg, rgba(32, 168, 168, 0.12) 0%, rgba(32, 168, 168, 0.06) 50%, rgba(32, 168, 168, 0.1) 100%);
background-size: 180% 180%;
background-position: 0% 50%;
box-shadow:
0 0 20px rgba(32, 168, 168, 0.15),
0 0 40px rgba(32, 168, 168, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.06),
inset 0 0 20px rgba(32, 168, 168, 0.03);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
animation: tvIdleGlow 2.1s ease-in-out infinite, tvSurfaceShift 2.8s linear infinite, tvButtonJitter 1.2s steps(2, end) infinite;
will-change: transform, box-shadow, background-position;
}
.btn-tv::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
opacity: 0.34;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(32, 168, 168, 0.03) 2px,
rgba(32, 168, 168, 0.03) 4px
);
transition: opacity 0.3s ease;
pointer-events: none;
animation: tvHorizontalRoll 2.2s linear infinite;
}
.btn-tv::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
opacity: 0.22;
pointer-events: none;
mix-blend-mode: screen;
background-image:
linear-gradient(180deg, rgba(32, 168, 168, 0) 0%, rgba(32, 168, 168, 0.1) 48%, rgba(32, 168, 168, 0) 100%),
repeating-linear-gradient(
0deg,
transparent 0px,
transparent 5px,
rgba(180, 255, 255, 0.045) 6px,
transparent 7px
);
background-size: 100% 100%, 100% 12px;
background-position: 0 0, 0 0;
animation: tvInterferenceShift 0.45s steps(14) infinite, tvNoSignalBurst 3.6s steps(1, end) infinite;
}
/* TV scanline sweep overlay */
.btn-tv .tv-scanline {
position: absolute;
inset: 0;
z-index: 2;
opacity: 0.75;
overflow: hidden;
pointer-events: none;
transition: opacity 0.3s ease;
}
.btn-tv .tv-scanline::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(
180deg,
transparent,
rgba(32, 168, 168, 0.08) 40%,
rgba(32, 168, 168, 0.12) 50%,
rgba(32, 168, 168, 0.08) 60%,
transparent
);
animation: tvScan 1.1s linear infinite;
}
/* Static noise overlay */
.btn-tv .tv-noise {
position: absolute;
inset: -50%;
z-index: 3;
opacity: 0.08;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.4'/%3E%3C/svg%3E");
background-size: 128px 128px;
background-position: 0 0;
pointer-events: none;
transition: opacity 0.3s ease;
mix-blend-mode: overlay;
animation: tvNoiseDrift 0.55s steps(24) infinite;
}
.btn-tv .tv-noise::after {
content: '';
position: absolute;
top: 48%;
left: -38%;
width: 34%;
height: 1px;
border-radius: 9999px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(32, 168, 168, 0.5) 40%,
rgba(155, 255, 255, 0.8) 50%,
rgba(32, 168, 168, 0.5) 60%,
transparent 100%
);
box-shadow: 0 0 10px rgba(32, 168, 168, 0.35);
opacity: 0.95;
height: 2px;
animation: tvNoiseLineX 1.15s linear infinite;
}
/* CRT vignette corners */
.btn-tv .tv-vignette {
position: absolute;
inset: 0;
z-index: 4;
opacity: 0;
border-radius: inherit;
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: opacity 0.4s ease;
}
/* Hover state: TV turns on */
.btn-tv:hover {
border-color: rgba(32, 168, 168, 0.5);
background: linear-gradient(135deg, rgba(32, 168, 168, 0.18) 0%, rgba(32, 168, 168, 0.08) 50%, rgba(32, 168, 168, 0.15) 100%);
box-shadow:
0 0 44px rgba(32, 168, 168, 0.45),
0 0 100px rgba(32, 168, 168, 0.22),
0 0 130px rgba(32, 168, 168, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 0 30px rgba(32, 168, 168, 0.06);
transform: translateY(-2px) scale(1.055);
}
.btn-tv:hover::before {
opacity: 0.75;
}
.btn-tv:hover::after {
opacity: 0.45;
}
.btn-tv:hover .tv-scanline {
opacity: 1;
}
.btn-tv:hover .tv-noise {
opacity: 0.16;
animation: tvNoiseDrift 0.35s steps(30) infinite, tvFlicker 0.08s infinite;
}
.btn-tv:hover .tv-noise::after {
opacity: 1;
animation-duration: 0.65s;
}
.btn-tv:hover .tv-vignette {
opacity: 1;
}
/* Glitch text on hover */
.btn-tv:hover .btn-tv-text {
text-shadow:
2px 0 rgba(32, 168, 168, 0.3),
-2px 0 rgba(255, 100, 100, 0.15);
animation: tvTextGlitch 4s infinite;
}
.btn-tv .btn-tv-text {
animation: tvSubpixelDrift 8s ease-in-out infinite;
}
@keyframes tvTextGlitch {
0%, 93%, 100% {
text-shadow:
0 0 rgba(32, 168, 168, 0),
0 0 rgba(255, 100, 100, 0);
}
94% {
text-shadow:
2px 0 rgba(32, 168, 168, 0.4),
-2px 0 rgba(255, 100, 100, 0.2);
}
95% {
text-shadow:
-1px 0 rgba(32, 168, 168, 0.3),
1px 0 rgba(255, 100, 100, 0.15);
}
96% {
text-shadow:
3px 0 rgba(32, 168, 168, 0.5),
-3px 0 rgba(255, 100, 100, 0.25);
}
97% {
text-shadow:
0 0 rgba(32, 168, 168, 0),
0 0 rgba(255, 100, 100, 0);
}
}
@keyframes tvIdleGlow {
0%, 100% {
box-shadow:
0 0 18px rgba(32, 168, 168, 0.14),
0 0 36px rgba(32, 168, 168, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 0 18px rgba(32, 168, 168, 0.02);
}
50% {
box-shadow:
0 0 24px rgba(32, 168, 168, 0.2),
0 0 48px rgba(32, 168, 168, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.07),
inset 0 0 24px rgba(32, 168, 168, 0.04);
}
}
@keyframes tvSurfaceShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes tvButtonJitter {
0%, 100% {
transform: translate3d(0, 0, 0);
}
20% {
transform: translate3d(0.4px, -0.3px, 0);
}
40% {
transform: translate3d(-0.5px, 0.2px, 0);
}
60% {
transform: translate3d(0.3px, 0.1px, 0);
}
80% {
transform: translate3d(-0.2px, -0.2px, 0);
}
}
@keyframes tvNoiseDrift {
0% {
transform: translate3d(-1%, -0.5%, 0) scale(1);
background-position: 0 0;
}
50% {
transform: translate3d(1%, 0.8%, 0) scale(1.015);
background-position: 22px -18px;
}
100% {
transform: translate3d(-0.5%, -0.2%, 0) scale(1);
background-position: 44px -8px;
}
}
@keyframes tvNoiseLineX {
0% {
transform: translateX(0);
}
100% {
transform: translateX(420%);
}
}
@keyframes tvHorizontalRoll {
0% {
transform: translateY(0);
}
100% {
transform: translateY(20px);
}
}
@keyframes tvInterferenceShift {
0% {
transform: translate3d(0, 0, 0);
background-position: 0 0, 0 0;
}
100% {
transform: translate3d(0, 0, 0);
background-position: 0 0, 0 12px;
}
}
@keyframes tvNoSignalBurst {
0%, 60%, 100% {
filter: contrast(1) brightness(1);
transform: translate3d(0, 0, 0);
}
61% {
filter: contrast(2.1) brightness(1.35) saturate(1.15);
transform: translate3d(1.3%, -0.5%, 0);
}
62% {
filter: contrast(0.72) brightness(0.8) saturate(0.9);
transform: translate3d(-1.4%, 0.4%, 0);
}
63% {
filter: contrast(1.6) brightness(1.2) saturate(1.1);
transform: translate3d(0.6%, -0.2%, 0);
}
}
@keyframes tvSubpixelDrift {
0%, 100% {
transform: translate3d(0, 0, 0);
text-shadow:
0 0 rgba(32, 168, 168, 0),
0 0 rgba(255, 110, 110, 0);
}
45% {
transform: translate3d(0.1px, 0, 0);
text-shadow:
0.5px 0 rgba(32, 168, 168, 0.2),
-0.5px 0 rgba(255, 110, 110, 0.08);
}
55% {
transform: translate3d(-0.1px, 0, 0);
text-shadow:
0.4px 0 rgba(32, 168, 168, 0.16),
-0.4px 0 rgba(255, 110, 110, 0.06);
}
a.card-panel:hover,
.card-panel:has(> a:hover) {
@apply border-surface-600 bg-surface-900/70;
}
/* ── Carousel infinite marquee ── */
@@ -432,16 +87,5 @@
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.btn-tv,
.btn-tv::before,
.btn-tv::after,
.btn-tv .tv-noise,
.btn-tv .tv-noise::after,
.btn-tv .tv-scanline::after,
.btn-tv .btn-tv-text,
.btn-tv:hover .btn-tv-text {
animation-duration: 16s !important;
}
}
}
+3 -35
View File
@@ -41,23 +41,18 @@ export default {
},
},
fontFamily: {
display: ['"Playfair Display"', 'Georgia', 'serif'],
display: ['"Bricolage Grotesque"', '"DM Sans"', 'system-ui', 'sans-serif'],
body: ['"DM Sans"', 'system-ui', 'sans-serif'],
mono: ['"IBM Plex Mono"', 'monospace'],
},
fontSize: {
'7xl': ['4.5rem', { lineHeight: '1.05', letterSpacing: '-0.03em' }],
'8xl': ['6rem', { lineHeight: '1', letterSpacing: '-0.04em' }],
'7xl': ['4.5rem', { lineHeight: '1.02', letterSpacing: '-0.03em' }],
'8xl': ['6rem', { lineHeight: '0.98', letterSpacing: '-0.035em' }],
'9xl': ['8rem', { lineHeight: '0.95', letterSpacing: '-0.04em' }],
},
animation: {
'fade-up': 'fadeUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards',
'fade-in': 'fadeIn 0.6s ease forwards',
'glow-pulse': 'glowPulse 4s ease-in-out infinite',
'scan': 'scan 8s linear infinite',
'tv-scan': 'tvScan 3s linear infinite',
'tv-flicker': 'tvFlicker 0.15s infinite',
'tv-glow': 'tvGlow 2s ease-in-out infinite',
},
keyframes: {
fadeUp: {
@@ -68,33 +63,6 @@ export default {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
glowPulse: {
'0%, 100%': { opacity: '0.4' },
'50%': { opacity: '0.8' },
},
scan: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100%)' },
},
tvScan: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100%)' },
},
tvFlicker: {
'0%': { opacity: '0.97' },
'5%': { opacity: '0.95' },
'10%': { opacity: '0.98' },
'15%': { opacity: '0.94' },
'20%': { opacity: '0.98' },
'50%': { opacity: '0.96' },
'80%': { opacity: '0.98' },
'90%': { opacity: '0.95' },
'100%': { opacity: '0.98' },
},
tvGlow: {
'0%, 100%': { boxShadow: '0 0 30px rgba(32, 168, 168, 0.3), 0 0 60px rgba(32, 168, 168, 0.1), inset 0 0 30px rgba(32, 168, 168, 0.05)' },
'50%': { boxShadow: '0 0 40px rgba(32, 168, 168, 0.4), 0 0 80px rgba(32, 168, 168, 0.15), inset 0 0 40px rgba(32, 168, 168, 0.08)' },
},
},
},
},
+10 -3
View File
@@ -867,15 +867,22 @@ path-only setting; extra flags are stored separately as `mpvPlayerArguments` and
Argument fields are line-oriented: one non-empty trimmed line becomes one argv
entry. IPTVnator prepends those custom entries before its stream-specific runtime
arguments, then keeps the stream URL last. This avoids shell parsing, keeps paths
with spaces safe, and preserves existing settings for users who never configured
extra arguments.
arguments. This avoids shell parsing, keeps paths with spaces safe, and preserves
existing settings for users who never configured extra arguments.
The arguments apply only when IPTVnator spawns a new external player process. If
MPV or VLC instance reuse is active and an existing process is reused, subsequent
streams are loaded through MPV IPC or VLC RC commands and new process arguments
are not re-applied until a fresh process starts.
VLC enables its TCP RC interface when content metadata requires progress
tracking or instance reuse is enabled. On Windows, these managed RC launches
also append `--rc-quiet` after custom arguments to suppress VLC's DOS console
while retaining TCP control. macOS/Linux and launches without an allocated RC
port receive no automatic quiet flag. Both launch-error and exit-code-1 retries
remove the app-generated RC flags, including `--rc-quiet`; custom arguments
continue to be prepended unchanged.
## Electron External Player Ownership
External MPV/VLC integration is split across focused main-process modules:
+40 -4
View File
@@ -69,6 +69,35 @@ timeouts but not the breaker, deliberately:
idle-based, see above — which outlives the 45 s half-open trial expiry and
would let a second trial in behind the first.
## Desktop preference and account feedback
Settings > General > Portal connections offers **Pause requests to unavailable
portals**, enabled by default. `Settings.portalConnectivityGuard` is default-on
for missing or non-boolean legacy values; only explicit `false` opts out. The
form stages edits until Save and persists the choice through the normal settings
store. `SETTINGS_UPDATE` mirrors it to Electron's `PORTAL_CONNECTIVITY_GUARD`
config key and applies it immediately. `SettingsEvents.bootstrapSettingsEvents`
loads that mirror before the renderer is loaded, so a saved opt-out already
applies to the first portal request after restarting.
The preference controls both Xtream and Stalker. A real preference transition
replaces the Electron guard and its weak set of admitted request tokens, clearing
existing cooldowns and ignoring completions from the old generation. Disabled
requests return no token and cannot contribute failures when the user re-enables
the guard. Saving an unchanged value preserves existing evidence.
`IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` (or `true`) still overrides an enabled
preference. The UI is capability-gated by `supportsPortalConnectivityGuard`
(`updateSettings` plus `resetHostConnectivityGuard`); the PWA has no client-side
switch for its shared server-wide guard.
Both account-info dialogs classify the existing cross-process guard message and
show a localized **Requests temporarily paused** explanation with **Retry now**.
Stalker keeps cached account data visible and offers the same retry beside the
paused refresh notice. Retry resets before requesting again; actual network
failures retain the generic unavailable state. These notices describe the last
request outcome, not a live countdown. The preference does not fix the separate
same-millisecond sibling-counting issue #1438.
## Rules
Being wrong here means refusing to talk to a portal that works, so every rule
@@ -141,10 +170,17 @@ Two more rules exist because of specific failure modes:
- **Siblings are not a streak.** Catalog initialization fans out three category
requests at once; one network hiccup failing all three is one piece of
evidence, not a trip. A failure counts only if its request started at or after
the moment the previous failure was recorded. Timestamps are millisecond
coarse, so this only separates siblings once a request actually took time —
which is precisely the expensive case worth protecting. Only a failure that
evidence, not a trip. Each admitted request receives a guard-wide monotonic
admission id. A counted failure saves the highest id admitted so far in that
endpoint's failure record, not just the id of the request that failed. During
that streak, only a request admitted beyond this boundary can add another
failure. This distinguishes
siblings from later attempts even if admission and completion all happen in
the same millisecond (#1438), regardless of completion order. Admission ids
are never reused when an endpoint's state is evicted. A recreated record has
no failure streak, so the first old in-flight failure can still count once;
its remaining siblings cannot add further links to that streak. Timestamps
still determine streak expiry, cooldown and trial timeout. Only a failure that
was actually counted may trip the threshold: a sibling settling after the open
window elapsed would otherwise start a fresh one off the existing count and
push the half-open trial past the intended cooldown.
@@ -46,7 +46,17 @@
@if (refreshFailed()) {
<div class="account-dialog__message">
{{ 'STALKER.ACCOUNT_INFO.REFRESH_FAILED' | translate }}
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'STALKER.ACCOUNT_INFO.REFRESH_FAILED'
) | translate
}}
@if (requestsPaused()) {
<button mat-button (click)="reload()">
{{ 'PORTALS.REQUESTS_PAUSED.RETRY' | translate }}
</button>
}
</div>
} @else if (loadState() === 'loading') {
<p class="account-dialog__loading-copy">
@@ -88,16 +98,28 @@
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
</div>
<h3>
{{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }}
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.TITLE'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
) | translate
}}
</h3>
<p>
{{
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
| translate
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
) | translate
}}
</p>
<button mat-flat-button color="primary" (click)="reload()">
{{ 'RETRY' | translate }}
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.RETRY'
: 'RETRY'
) | translate
}}
</button>
</section>
} @else if (loadState() === 'loading') {
@@ -84,6 +84,33 @@ describe('StalkerAccountInfoComponent', () => {
};
});
it.each([false, true])(
'explains a paused refresh with cached snapshot=%s and allows retry',
async (cached) => {
if (cached)
playlistsService.getPlaylist.mockReturnValue(
of({ ...playlist, stalkerAccountInfo: cachedSnapshot })
);
accountInfoService.fetchAccountInfo.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'STALKER_REQUEST': Portal http://portal.example is not responding; skipped after repeated connection failures"
)
);
await createComponent();
expect(component.requestsPaused()).toBe(true);
expect(component.isCachedSnapshot()).toBe(cached);
expect(fixture.nativeElement.textContent).toContain(
'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
);
expect(fixture.nativeElement.textContent).toContain(
'PORTALS.REQUESTS_PAUSED.RETRY'
);
await component.reload();
expect(component.requestsPaused()).toBe(false);
expect(component.refreshFailed()).toBe(false);
}
);
it('shows the fresh snapshot when the portal answers', async () => {
await createComponent();
@@ -23,6 +23,7 @@ import {
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
isHostConnectivityFastFailMessage,
PlaylistMeta,
type StalkerAccountInfoDialogData,
} from '@iptvnator/shared/interfaces';
@@ -56,9 +57,8 @@ const DAY_IN_MS = 86_400_000;
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StalkerAccountInfoComponent {
private readonly data = inject<StalkerAccountInfoDialogData>(
MAT_DIALOG_DATA
);
private readonly data =
inject<StalkerAccountInfoDialogData>(MAT_DIALOG_DATA);
private readonly accountInfoService = inject(StalkerAccountInfoService);
private readonly dataService = inject(DataService);
private readonly playlistsService = inject(PlaylistsService);
@@ -72,6 +72,7 @@ export class StalkerAccountInfoComponent {
*/
readonly isFullPortal = isFullStalkerPortalPlaylist(this.playlist);
readonly loadState = signal<AccountLoadState>('loading');
readonly requestsPaused = signal(false);
readonly snapshot = signal<StalkerAccountSnapshot | null>(null);
readonly snapshotSource = signal<SnapshotSource | null>(null);
readonly refreshFailed = signal(false);
@@ -242,6 +243,7 @@ export class StalkerAccountInfoComponent {
}
private async load(): Promise<void> {
this.requestsPaused.set(false);
this.refreshFailed.set(false);
if (!this.snapshot()) {
this.loadState.set('loading');
@@ -259,6 +261,11 @@ export class StalkerAccountInfoComponent {
}
this.loadState.set('ready');
} catch (error) {
this.requestsPaused.set(
isHostConnectivityFastFailMessage(
error instanceof Error ? error.message : error
)
);
this.logger.error('Failed to fetch account info', error);
if (this.snapshot()) {
// Keep showing the cached snapshot; just flag the refresh.
@@ -91,27 +91,43 @@
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
</div>
<h3>
{{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }}
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.TITLE'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
) | translate
}}
</h3>
<p>
{{
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
| translate
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.DESCRIPTION'
: 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
) | translate
}}
</p>
<button mat-flat-button color="primary" (click)="reload()">
{{ 'RETRY' | translate }}
{{
(requestsPaused()
? 'PORTALS.REQUESTS_PAUSED.RETRY'
: 'RETRY'
) | translate
}}
</button>
</section>
} @else if (loadState() === 'loading') {
<div class="account-dialog__grid">
@for (panel of skeletonPanels; track panel) {
<section class="account-panel account-panel--loading">
<div class="account-skeleton account-skeleton--heading"></div>
<div
class="account-skeleton account-skeleton--heading"
></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row short"></div>
<div
class="account-skeleton account-skeleton--row short"
></div>
</section>
}
</div>
@@ -84,6 +84,38 @@ describe('AccountInfoComponent', () => {
await fixture.whenStable();
});
it('explains a guard refusal and clears the notice on successful retry', async () => {
xtreamApiService.getAccountInfo.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'XTREAM_REQUEST': Portal https://portal.example is not responding; skipped after repeated connection failures"
)
);
await component.reload();
fixture.detectChanges();
expect(component.requestsPaused()).toBe(true);
expect(fixture.nativeElement.textContent).toContain(
'PORTALS.REQUESTS_PAUSED.TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PORTALS.REQUESTS_PAUSED.RETRY'
);
await component.reload();
expect(component.requestsPaused()).toBe(false);
expect(component.loadState()).toBe('ready');
});
it('keeps actual network errors distinct from a paused request', async () => {
xtreamApiService.getAccountInfo.mockRejectedValueOnce(
new Error('connect ECONNREFUSED')
);
await component.reload();
fixture.detectChanges();
expect(component.requestsPaused()).toBe(false);
expect(fixture.nativeElement.textContent).toContain(
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE'
);
});
it('loads account info from dialog-supplied playlist credentials', () => {
expect(xtreamApiService.getAccountInfo).toHaveBeenCalledWith({
serverUrl: 'https://dialog.example.test',
@@ -15,11 +15,9 @@ import {
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import { DataService, resetHostConnectivityGuard } from '@iptvnator/services';
import {
DataService,
resetHostConnectivityGuard,
} from '@iptvnator/services';
import {
isHostConnectivityFastFailMessage,
resolveXtreamPortalStatus,
type XtreamAccountInfoDialogData,
} from '@iptvnator/shared/interfaces';
@@ -67,6 +65,7 @@ export class AccountInfoComponent {
() => this.data.playlist ?? this.xtreamStore.currentPlaylist()
);
readonly loadState = signal<AccountLoadState>('loading');
readonly requestsPaused = signal(false);
readonly accountInfo = signal<XtreamAccountInfo | null>(null);
readonly skeletonStats = [1, 2, 3, 4];
readonly skeletonPanels = [1, 2];
@@ -241,6 +240,7 @@ export class AccountInfoComponent {
}
private async load(): Promise<void> {
this.requestsPaused.set(false);
const playlist = this.currentPlaylist();
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
@@ -261,6 +261,11 @@ export class AccountInfoComponent {
this.accountInfo.set(accountInfo);
this.loadState.set('ready');
} catch (error) {
this.requestsPaused.set(
isHostConnectivityFastFailMessage(
error instanceof Error ? error.message : error
)
);
this.logger.error('Failed to fetch account info', error);
this.accountInfo.set(null);
this.loadState.set('error');
@@ -10,6 +10,16 @@ describe('RuntimeCapabilitiesService', () => {
testWindow.electron = originalElectron;
});
it('offers the portal cooldown setting only with both desktop bridge methods', () => {
const service = new RuntimeCapabilitiesService();
testWindow.electron = { updateSettings: jest.fn() };
expect(service.supportsPortalConnectivityGuard).toBe(false);
testWindow.electron['resetHostConnectivityGuard'] = jest.fn();
expect(service.supportsPortalConnectivityGuard).toBe(true);
testWindow.electron = undefined;
expect(service.supportsPortalConnectivityGuard).toBe(false);
});
it('reports browser PWA capabilities when the Electron bridge is absent', () => {
testWindow.electron = undefined;
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more