mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* fix(playback): apply themes to player and EPG panels * test(playback): verify active recording icon theme * fix(epg): keep loading shimmer visible in both themes
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
import { writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { pathToFileURL } from 'url';
|
|
import {
|
|
channelItemByTitle,
|
|
closeElectronApp,
|
|
expect,
|
|
goToDashboard,
|
|
importM3uPlaylistFromNativeDialog,
|
|
launchElectronApp,
|
|
openSettings,
|
|
openGlobalFavorites,
|
|
openGlobalRecent,
|
|
openSettingsSection,
|
|
saveSettings,
|
|
test,
|
|
workspaceRoot,
|
|
} from './electron-test-fixtures';
|
|
import {
|
|
createLocalMediaServer,
|
|
installEmbeddedMpvSessionCapture,
|
|
} from './embedded-mpv-frame-copy-packaged-fixtures';
|
|
import {
|
|
applyTheme,
|
|
expectTextContrast,
|
|
expectThemeSurface,
|
|
expectOverlayContrastOnWhite,
|
|
} from './theme-contrast';
|
|
|
|
for (const engine of [
|
|
'native',
|
|
'frame-copy',
|
|
'html5',
|
|
'videojs',
|
|
'artplayer',
|
|
] as const) {
|
|
test(`@playback @theme @electron ${engine} keeps controls readable across live theme and fullscreen changes`, async ({
|
|
dataDir,
|
|
}) => {
|
|
const embedded = engine === 'native' || engine === 'frame-copy';
|
|
const media = embedded ? await createLocalMediaServer() : undefined;
|
|
const app = await launchElectronApp(dataDir, {
|
|
env: {
|
|
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
|
|
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
|
|
engine === 'frame-copy' ? '1' : '0',
|
|
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
|
|
},
|
|
});
|
|
try {
|
|
if (embedded) {
|
|
const support = await app.mainWindow.evaluate(() =>
|
|
window.electron.getEmbeddedMpvSupport()
|
|
);
|
|
test.skip(
|
|
!support.supported || support.engine !== engine,
|
|
`Requested ${engine} runtime unavailable: ${JSON.stringify(support)}`
|
|
);
|
|
}
|
|
await openSettings(app.mainWindow);
|
|
await app.mainWindow.getByTestId('LIGHT_THEME').click();
|
|
await openSettingsSection(app.mainWindow, 'playback');
|
|
await app.mainWindow.getByTestId('select-video-player').click();
|
|
await app.mainWindow
|
|
.getByTestId(embedded ? 'embedded-mpv' : engine)
|
|
.click();
|
|
await saveSettings(app.mainWindow);
|
|
await goToDashboard(app.mainWindow);
|
|
const playlist = join(dataDir, 'theme.m3u');
|
|
const url =
|
|
media?.url ??
|
|
pathToFileURL(
|
|
join(
|
|
workspaceRoot,
|
|
'apps/web-e2e/src/fixtures/playback/episode.webm'
|
|
)
|
|
).href;
|
|
writeFileSync(
|
|
playlist,
|
|
`#EXTM3U\n#EXTINF:-1,Theme fixture\n${url}\n`
|
|
);
|
|
if (embedded) await installEmbeddedMpvSessionCapture(app);
|
|
await importM3uPlaylistFromNativeDialog(app, playlist);
|
|
if (engine === 'html5') {
|
|
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
|
.first()
|
|
.locator('.favorite-button')
|
|
.first()
|
|
.click();
|
|
}
|
|
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
|
.first()
|
|
.click();
|
|
if (engine === 'frame-copy') {
|
|
// The experimental local runtime can fail its first frame-view
|
|
// initialization. Cover the themed error UI and one user Retry;
|
|
// successful decoding is still required below.
|
|
await expect
|
|
.poll(() =>
|
|
app.mainWindow.evaluate(
|
|
() =>
|
|
(window.__packagedEmbeddedMpvSessions?.at(-1)
|
|
?.positionSeconds ?? 0) > 0 ||
|
|
!!document.querySelector(
|
|
'.embedded-mpv-player__stalled'
|
|
)
|
|
)
|
|
)
|
|
.toBe(true);
|
|
const stalled = app.mainWindow.locator(
|
|
'.embedded-mpv-player__stalled'
|
|
);
|
|
if (await stalled.isVisible()) {
|
|
test.info().annotations.push({
|
|
type: 'runtime-retry',
|
|
description:
|
|
'Frame-copy initialization required one user Retry before theme checks.',
|
|
});
|
|
for (const theme of ['dark', 'light'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
await expectTextContrast(stalled.locator('p'));
|
|
await expectTextContrast(
|
|
stalled.getByRole('button', { name: 'Retry' })
|
|
);
|
|
}
|
|
await stalled
|
|
.getByRole('button', { name: 'Retry' })
|
|
.click();
|
|
}
|
|
}
|
|
if (embedded) {
|
|
await expect
|
|
.poll(() =>
|
|
app.mainWindow.evaluate(
|
|
() =>
|
|
window.__packagedEmbeddedMpvSessions?.at(-1)
|
|
?.positionSeconds ?? 0
|
|
)
|
|
)
|
|
.toBeGreaterThan(0);
|
|
} else {
|
|
await expect
|
|
.poll(() =>
|
|
app.mainWindow
|
|
.locator('app-web-player-view video')
|
|
.evaluate(
|
|
(video: HTMLVideoElement) => video.currentTime
|
|
)
|
|
)
|
|
.toBeGreaterThan(0);
|
|
}
|
|
const controls = app.mainWindow.locator(
|
|
engine === 'native'
|
|
? '.embedded-mpv-player__controls'
|
|
: 'app-player-controls'
|
|
);
|
|
const pause = controls.getByRole('button', {
|
|
name: 'Pause',
|
|
exact: true,
|
|
});
|
|
await expect(pause).toBeEnabled({ timeout: 20000 });
|
|
await pause.click();
|
|
const play = controls.getByRole('button', {
|
|
name: 'Play',
|
|
exact: true,
|
|
});
|
|
await expect(play).toBeVisible();
|
|
for (const fullscreen of [false, true]) {
|
|
if (fullscreen) {
|
|
await controls
|
|
.getByRole('button', {
|
|
name: 'Enter fullscreen',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
}
|
|
for (const theme of ['light', 'dark', 'light'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
await play.hover();
|
|
if (engine === 'native') await expectTextContrast(play, 3);
|
|
else await expectOverlayContrastOnWhite(controls, play);
|
|
// Leave pointer hover, then Tab back into the control to
|
|
// exercise a real keyboard focus-visible state.
|
|
await play.press('Tab');
|
|
await app.mainWindow.keyboard.press('Shift+Tab');
|
|
await expect(play).toBeFocused();
|
|
if (engine === 'native') await expectTextContrast(play, 3);
|
|
else await expectOverlayContrastOnWhite(controls, play);
|
|
if (!fullscreen) {
|
|
const timeline =
|
|
app.mainWindow.locator('app-epg-timeline');
|
|
await expectThemeSurface(timeline, theme);
|
|
await expectTextContrast(
|
|
timeline.locator('.epg-empty__title')
|
|
);
|
|
await expectTextContrast(
|
|
timeline.locator('.epg-empty__sub')
|
|
);
|
|
}
|
|
if (engine === 'native') {
|
|
await expectThemeSurface(controls, theme);
|
|
const record = controls.locator(
|
|
'.embedded-mpv-player__record-button'
|
|
);
|
|
await expect(record).toBeVisible();
|
|
// Recording behavior has its own IPC coverage; exercise
|
|
// the state class here without creating a recording file.
|
|
await record.evaluate((element) =>
|
|
element.classList.add(
|
|
'embedded-mpv-player__record-button--active'
|
|
)
|
|
);
|
|
const liveColor = await record.evaluate((element) =>
|
|
getComputedStyle(element)
|
|
.getPropertyValue('--app-live-color')
|
|
.trim()
|
|
);
|
|
await expect(record.locator('mat-icon')).toHaveCSS(
|
|
'color',
|
|
await record.evaluate((element, color) => {
|
|
const probe = document.createElement('span');
|
|
probe.style.color = color;
|
|
element.append(probe);
|
|
const resolved = getComputedStyle(probe).color;
|
|
probe.remove();
|
|
return resolved;
|
|
}, liveColor)
|
|
);
|
|
await expectTextContrast(record, 3);
|
|
await record.evaluate((element) =>
|
|
element.classList.remove(
|
|
'embedded-mpv-player__record-button--active'
|
|
)
|
|
);
|
|
await expect(play).toHaveCSS('outline-width', '2px');
|
|
const disabled = controls
|
|
.locator(
|
|
'.embedded-mpv-player__transport button:disabled'
|
|
)
|
|
.first();
|
|
await expect(disabled).toBeDisabled();
|
|
await expectTextContrast(disabled, 1.5);
|
|
expect(
|
|
await disabled.evaluate(
|
|
(el) => getComputedStyle(el).color
|
|
)
|
|
).not.toEqual(
|
|
await play.evaluate(
|
|
(el) => getComputedStyle(el).color
|
|
)
|
|
);
|
|
await expectTextContrast(
|
|
controls.locator('.embedded-mpv-player__time')
|
|
);
|
|
}
|
|
if (engine === 'native') {
|
|
await controls
|
|
.locator('[data-embedded-mpv-menu-button="speed"]')
|
|
.click();
|
|
const panel = controls.locator(
|
|
'app-embedded-mpv-dock-panel'
|
|
);
|
|
await expectTextContrast(
|
|
panel.locator('.embedded-mpv-dock-panel__title')
|
|
);
|
|
const selected = panel.locator(
|
|
'.embedded-mpv-dock-panel__chip--selected'
|
|
);
|
|
await expectTextContrast(selected);
|
|
await selected.hover();
|
|
await expectTextContrast(selected);
|
|
await panel
|
|
.locator('.embedded-mpv-dock-panel__back')
|
|
.click();
|
|
}
|
|
const controlBox = await controls.boundingBox();
|
|
const playBox = await play.boundingBox();
|
|
expect(playBox!.x).toBeGreaterThanOrEqual(controlBox!.x);
|
|
expect(playBox!.y + playBox!.height).toBeLessThanOrEqual(
|
|
controlBox!.y + controlBox!.height + 1
|
|
);
|
|
await app.mainWindow.screenshot({
|
|
path: test
|
|
.info()
|
|
.outputPath(
|
|
`${engine}-${theme}-${fullscreen ? 'fullscreen' : 'window'}.png`
|
|
),
|
|
});
|
|
}
|
|
}
|
|
await controls
|
|
.getByRole('button', { name: 'Exit fullscreen', exact: true })
|
|
.click();
|
|
if (engine === 'html5') {
|
|
for (const openCollection of [
|
|
openGlobalFavorites,
|
|
openGlobalRecent,
|
|
]) {
|
|
await openCollection(app.mainWindow);
|
|
await channelItemByTitle(app.mainWindow, 'Theme fixture')
|
|
.first()
|
|
.click();
|
|
for (const theme of ['light', 'dark'] as const) {
|
|
await applyTheme(app.mainWindow, theme);
|
|
const timeline =
|
|
app.mainWindow.locator('app-epg-timeline');
|
|
await expect(timeline).toBeVisible();
|
|
await expectThemeSurface(timeline, theme);
|
|
await expectTextContrast(
|
|
timeline.locator('.epg-timeline__heading b')
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await media?.close();
|
|
}
|
|
});
|
|
}
|