fix(ui): restore channel and detail keyboard scrolling (#1542)

* fix(ui): restore channel and detail keyboard scrolling

* test(ui): drag below the Windows scrollbar arrow
This commit is contained in:
4gray authored and GitHub committed 2026-09-05 15:02:57 +02:00
1 parent 0ba5107561
commit eb602db5fc
44 files changed
+950 -49

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: ui
issues: [1506]
---
Live TV now supports keyboard movement between categories and channels, and channel lists keep scrolling after mouse selection. Channel scrollbars and resize handles are independently accessible. Movie and series details support immediate keyboard scrolling and no longer hide their scrollbars.
+3 -3
View File
@@ -40,9 +40,9 @@ consumers currently use relative `@use` paths to the needed partial.
of copied SCSS. of copied SCSS.
- In an Electron drag region, every interactive descendant—buttons, links, - In an Electron drag region, every interactive descendant—buttons, links,
inputs, overlays, and resize handles—must explicitly use inputs, overlays, and resize handles—must explicitly use
`app-region: no-drag`. The shared directive-generated `.resize-handle` does `app-region: no-drag`. The shared directive-generated `.resize-handle` sets
not set this centrally yet; consumers in drag regions must cover it this centrally. Shared live sidebars reserve 8 px between their scroll
themselves and must not assume the generated handle opts out. content and the edge so scrollbar and resize hit areas stay independent.
- A shared change must be checked across M3U, Xtream, Stalker, workspace, - A shared change must be checked across M3U, Xtream, Stalker, workspace,
portal catalog/shared UI, and unified collections where relevant. portal catalog/shared UI, and unified collections where relevant.
+12
View File
@@ -188,6 +188,18 @@ categories by provider ID and type. See `docs/architecture/category-management.m
copy and Retry now; Stalker preserves cached account data on a failed refresh. copy and Retry now; Stalker preserves cached account data on a failed refresh.
Contract: `docs/architecture/host-connectivity-guard.md`. Contract: `docs/architecture/host-connectivity-guard.md`.
## Channel and Detail Keyboard Scrolling
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
focuses the viewport, native scrolling survives virtual row recycling, and
row Enter/Space activation stays separate from focus movement. Portal Live TV
uses ArrowRight from the selected category and ArrowLeft from the channels
pane to move between columns. Shared live sidebars reserve scrollbar space
beside the resize handle. `PortalDetailShellComponent` owns a visible native
scrollbar and guarded initial page focus. Contracts:
`docs/architecture/iptvnator-ui-guidelines.md` and
`docs/architecture/portal-detail-navigation.md`.
## Radio / Audio Player ## Radio / Audio Player
M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected: M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected:
+12
View File
@@ -1666,3 +1666,15 @@ No formal migration system yet. Schema changes are applied via raw SQL in the `c
- Both account-info dialogs explain guard refusals with localized paused-request - Both account-info dialogs explain guard refusals with localized paused-request
copy and Retry now; Stalker preserves cached account data on a failed refresh. copy and Retry now; Stalker preserves cached account data on a failed refresh.
Contract: `docs/architecture/host-connectivity-guard.md`. Contract: `docs/architecture/host-connectivity-guard.md`.
## Channel and Detail Keyboard Scrolling
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
focuses the viewport, native scrolling survives virtual row recycling, and
row Enter/Space activation stays separate from focus movement. Portal Live TV
uses ArrowRight from the selected category and ArrowLeft from the channels
pane to move between columns. Shared live sidebars reserve scrollbar space
beside the resize handle. `PortalDetailShellComponent` owns a visible native
scrollbar and guarded initial page focus. Contracts:
`docs/architecture/iptvnator-ui-guidelines.md` and
`docs/architecture/portal-detail-navigation.md`.
@@ -329,3 +329,88 @@ test.describe('Electron Xtream Responsiveness', () => {
} }
}); });
}); });
test('keeps Live TV scrolling, row actions and resizing independently accessible', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await addXtreamPortal(page);
await waitForXtreamCatalog(page);
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
const categories = page.locator('.context-panel .category-item');
const category = categories.first();
await category.click();
const viewport = page.locator('.scroll-viewport-portals');
await expect(viewport).toBeVisible();
await category.focus();
await page.keyboard.press('ArrowRight');
await expect(viewport).toBeFocused();
await expect(page.locator('app-web-player-view')).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(
viewport.locator('button.channel-content').first()
).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(viewport).toBeFocused();
await viewport.locator('.channel-name').first().click();
await expect(viewport).toBeFocused();
await page.keyboard.press('PageDown');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(100);
const position = await viewport.evaluate((el) => el.scrollTop);
await page.keyboard.press('PageDown');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(position);
await page.keyboard.press('Home');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBe(0);
const before = (await viewport.boundingBox())!;
const handle = page.locator(
'app-live-stream-layout .sidebar > .resize-handle'
);
const resize = (await handle.boundingBox())!;
expect(before.x + before.width).toBeLessThanOrEqual(resize.x);
expect(
await handle.evaluate((el) =>
getComputedStyle(el).getPropertyValue('app-region')
)
).toBe('no-drag');
// Start inside the thumb, below the native Windows up-arrow button.
// The 40-channel mock leaves a thumb taller than 100 px here.
await page.mouse.move(before.x + before.width - 3, before.y + 40);
await page.mouse.down();
await page.mouse.move(before.x + before.width - 3, before.y + 160, {
steps: 10,
});
await page.mouse.up();
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(100);
expect((await viewport.boundingBox())!.width).toBe(before.width);
await page.mouse.move(resize.x + 2, resize.y + 100);
await page.mouse.down();
await page.mouse.move(resize.x + 62, resize.y + 100, { steps: 10 });
await page.mouse.up();
await expect
.poll(async () => (await viewport.boundingBox())!.width)
.toBeGreaterThan(before.width + 30);
await viewport.focus();
await page.keyboard.press('ArrowLeft');
await expect(category).toBeFocused();
await categories.nth(1).focus();
await page.keyboard.press('Enter');
await expect(categories.nth(1)).toBeFocused();
await expect(categories.nth(1)).toHaveAttribute('aria-current', 'true');
await page.keyboard.press('ArrowRight');
await expect(viewport).toBeFocused();
} finally {
await closeElectronApp(app);
}
});
@@ -133,3 +133,67 @@ test.describe('Xtream VOD Details', () => {
} }
}); });
}); });
for (const theme of ['light', 'dark']) {
test(`supports keyboard and mouse scrolling in portal details (${theme})`, async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await page.setViewportSize({ width: 1200, height: 540 });
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
for (const section of ['Movies', 'Series']) {
await page
.getByRole('link', { name: section, exact: true })
.click();
await page.locator('app-grid-list mat-card').first().click();
const shell = page.locator('app-portal-detail-shell');
await expect(shell).toBeFocused();
await page.evaluate(
(dark) =>
document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
await expect
.poll(() =>
shell.evaluate(
(el) => el.scrollHeight - el.clientHeight
)
)
.toBeGreaterThan(0);
expect(
await shell.evaluate(
(el) => getComputedStyle(el).scrollbarWidth
)
).not.toBe('none');
await page.keyboard.press('PageDown');
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await page.keyboard.press('Home');
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBe(0);
const box = (await shell.boundingBox())!;
await page.mouse.move(
box.x + box.width / 2,
box.y + box.height / 2
);
await page.mouse.wheel(0, 300);
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await page.keyboard.press('Tab');
await expect(shell.locator('.hero__back-button')).toBeFocused();
await page.keyboard.press('Enter');
await expect(shell).toHaveCount(0);
}
} finally {
await closeElectronApp(app);
}
});
}
+19
View File
@@ -52,3 +52,22 @@ export async function postWithRetry(
throw lastError; throw lastError;
} }
/** Wait for native scrolling to settle before the next discrete keyboard action. */
export async function waitForScrollIdle(scrollOwner: Locator): Promise<void> {
await scrollOwner.evaluate(
(element) =>
new Promise<void>((resolve) => {
let last = element.scrollTop;
let stableFrames = 0;
const frame = () => {
stableFrames =
element.scrollTop === last ? stableFrames + 1 : 0;
last = element.scrollTop;
if (stableFrames === 3) resolve();
else requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
})
);
}
+73 -5
View File
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { expect, test } from './fixtures'; import { expect, test } from './fixtures';
import { waitForScrollIdle } from './e2e-helpers';
/** /**
* The M3U movie-recognition workflow end to end: a playlist entry whose URL * The M3U movie-recognition workflow end to end: a playlist entry whose URL
@@ -112,20 +113,22 @@ async function enableTmdb(page: Page): Promise<void> {
await saveSettings(page); await saveSettings(page);
} }
async function importPlaylist(page: Page): Promise<void> { async function importPlaylist(
page: Page,
content = MOVIE_PLAYLIST,
count = 2
): Promise<void> {
await page.goto('/'); await page.goto('/');
await page.getByRole('button', { name: 'Add playlist' }).click(); await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog await dialog.getByLabel('Insert m3u(8) playlist as text').fill(content);
.getByLabel('Insert m3u(8) playlist as text')
.fill(MOVIE_PLAYLIST);
await Promise.all([ await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/), page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(), dialog.getByRole('button', { name: 'Import', exact: true }).click(),
]); ]);
await expect(page.getByText('2 channels')).toBeVisible(); await expect(page.getByText(`${count} channels`)).toBeVisible();
} }
const detail = (page: Page) => page.locator('app-m3u-vod-detail'); const detail = (page: Page) => page.locator('app-m3u-vod-detail');
@@ -248,3 +251,68 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
) )
.toBe(0.25); .toBe(0.25);
}); });
for (const theme of ['light', 'dark']) {
test(`@web @m3u channel scrolling keeps focus after selection (${theme})`, async ({
page,
}) => {
await serveStreams(page);
await selectHtml5Player(page);
const channels = Array.from(
{ length: 60 },
(_, index) =>
`#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.m3u8`
);
await importPlaylist(page, ['#EXTM3U', ...channels].join('\n'), 60);
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const viewport = page.locator(
'app-all-channels-view cdk-virtual-scroll-viewport'
);
await viewport.locator('.channel-name').first().click();
await expect(viewport).toBeFocused();
await page.keyboard.press('PageDown');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(100);
await expect(viewport).toBeFocused();
await waitForScrollIdle(viewport);
await page.keyboard.press('Home');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBe(0);
await page.keyboard.press('Tab');
await expect(
viewport.locator('button.channel-content').first()
).toBeFocused();
await viewport.locator('button.channel-content').nth(1).focus();
await page.keyboard.press('Enter');
await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass(
/active/
);
await page.keyboard.press('Tab');
const favorite = viewport.locator('.favorite-button').nth(1);
await expect(favorite).toBeFocused();
await page.keyboard.press('Space');
await expect(favorite).toBeFocused();
await page
.getByRole('link', { name: 'Global favorites', exact: true })
.click();
const favorites = page.locator(
'app-global-favorites-list [appChannelScrollFocus]'
);
await favorites.locator('.channel-name').first().click();
await expect(favorites).toBeFocused();
await page
.getByRole('link', { name: 'Recently viewed', exact: true })
.first()
.click();
const recent = page.locator(
'app-global-favorites-list [appChannelScrollFocus]'
);
await recent.locator('.channel-name').first().click();
await expect(recent).toBeFocused();
});
}
+12
View File
@@ -480,10 +480,22 @@ test('@stalker ITV playback survives a category switch', async ({ page }) => {
const sidebarTitle = sidebar.locator('.category-title'); const sidebarTitle = sidebar.locator('.category-title');
const channels = page.locator('[data-test-id="channel-item"]'); const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 }); await expect(channels.first()).toBeVisible({ timeout: 20_000 });
const scrollPane = sidebar.locator('#live-channels');
await categories.nth(1).focus();
await page.keyboard.press('ArrowRight');
await expect(scrollPane).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(categories.nth(1)).toBeFocused();
const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? ''; const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? '';
expect(firstCategoryTitle).not.toBe(''); expect(firstCategoryTitle).not.toBe('');
await channels.first().click(); await channels.first().click();
await expect(scrollPane).toBeFocused();
await page.keyboard.press('PageDown');
await expect
.poll(() => scrollPane.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
const player = page.locator('app-web-player-view'); const player = page.locator('app-web-player-view');
await expect(player).toBeVisible({ timeout: 20_000 }); await expect(player).toBeVisible({ timeout: 20_000 });
+105 -1
View File
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { APIRequestContext, Locator, Page } from '@playwright/test'; import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect, test } from './fixtures'; import { expect, test } from './fixtures';
import { setInputValue } from './e2e-helpers'; import { setInputValue, waitForScrollIdle } from './e2e-helpers';
import { import {
getRegisteredProviderUrl, getRegisteredProviderUrl,
interceptProviderTargetRegistration, interceptProviderTargetRegistration,
@@ -1219,3 +1219,107 @@ test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
.toBe(true); .toBe(true);
}); });
}); });
for (const theme of ['light', 'dark']) {
test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({
page,
}) => {
await addXtreamPortal(page);
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
const category = page.locator('.context-panel .category-item').first();
await category.click();
const viewport = page.locator('.scroll-viewport-portals');
await expect(viewport).toBeVisible();
await category.focus();
await page.keyboard.press('ArrowRight');
await expect(viewport).toBeFocused();
await expect(page.locator('app-web-player-view')).toHaveCount(0);
await page.keyboard.press('Tab');
const firstAction = viewport.locator('button.channel-content').first();
await expect(firstAction).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(viewport).toBeFocused();
const row = viewport.locator('.channel-name').first();
await row.click();
await expect(viewport).toBeFocused();
await page.keyboard.press('PageDown');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(100);
await waitForScrollIdle(viewport);
const position = await viewport.evaluate((el) => el.scrollTop);
await page.keyboard.press('PageDown');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBeGreaterThan(position);
await waitForScrollIdle(viewport);
await page.keyboard.press('Home');
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBe(0);
await firstAction.focus();
await page.keyboard.press('Space');
await expect(page.locator('app-web-player-view')).toBeVisible();
await expect(firstAction).toBeFocused();
await page.keyboard.press('Tab');
await expect(
viewport.locator('.favorite-button').first()
).toBeFocused();
await page.keyboard.press('Space');
await expect(
viewport.locator('.favorite-button').first()
).toBeFocused();
await viewport.focus();
const overlap = await viewport.evaluate((el) => {
const handle = el
.closest('.sidebar')!
.querySelector('.resize-handle')!;
return (
el.getBoundingClientRect().right -
handle.getBoundingClientRect().left
);
});
expect(overlap).toBeLessThanOrEqual(0);
await page.keyboard.press('ArrowLeft');
await expect(category).toBeFocused();
});
for (const section of ['Movies', 'Series']) {
test(`@xtream navigation: detail scroll ${section} (${theme})`, async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 540 });
await addXtreamPortal(page);
await page
.getByRole('link', { name: section, exact: true })
.click();
await page.locator('app-grid-list mat-card').first().click();
const shell = page.locator('app-portal-detail-shell');
await expect(shell).toBeVisible();
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
await expect(shell).toBeFocused();
await expect
.poll(() =>
shell.evaluate((el) => el.scrollHeight - el.clientHeight)
)
.toBeGreaterThan(0);
expect(
await shell.evaluate(
(el) => getComputedStyle(el).scrollbarWidth
)
).not.toBe('none');
await page.keyboard.press('PageDown');
await expect
.poll(() => shell.evaluate((el) => el.scrollTop))
.toBeGreaterThan(0);
});
}
}
+5
View File
@@ -261,3 +261,8 @@ textarea,
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
.channel-scroll-focus:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
+25 -3
View File
@@ -120,9 +120,31 @@ the wrapper file that includes it.
Every interactive descendant of a drag region—including buttons, links, Every interactive descendant of a drag region—including buttons, links,
inputs, overlays, and resize handles—requires `app-region: no-drag`. The shared inputs, overlays, and resize handles—requires `app-region: no-drag`. The shared
directive-generated `.resize-handle` does not set this centrally yet. Until directive-generated `.resize-handle` sets this centrally in `resizable.scss`.
that debt is fixed, consumers in drag regions must cover the handle themselves The shared live-layout sidebar reserves 8 px at its right edge so the inward
and must not assume it already opts out. half of the 12 px resize handle cannot cover the channel scrollbar.
## Keyboard Scrolling and Channel Focus
`ChannelScrollFocusDirective` belongs on the actual channel scroll owner,
including virtual viewports and nonvirtual Favorites/Recent/Stalker lists.
Pointer selection focuses that owner without moving its scroll position.
ArrowUp/Down, PageUp/Down, Home/End and Space retain native scrolling there;
scroll keys do not bubble into document-level player shortcuts. A row's main
button remains separate from favorite/info actions, supports native Enter and
Space activation, and retains keyboard focus on activation. Tab/Shift+Tab use
the normal DOM order. Scrolling from a virtual row moves focus to its viewport
before CDK can recycle the row; asynchronous data updates never move focus.
Xtream aligns a newly selected channel only when it is outside the viewport;
updates to the same selected ID never re-align it. A smooth scroll to an
already visible row would otherwise cancel an immediate keyboard scroll.
In portal Live TV, ArrowRight on the selected category enters the visible
`live-channels` region; ArrowLeft from that region or a channel's main button
returns to the selected category in `portal-categories`. These IDs identify the
single mounted main pane, not fullscreen or overlay lists. Navigation does not
select a channel or start playback. Modified shortcuts, input fields, menus,
dialogs, player controls and hidden/inert panes keep their own behavior.
## Channel List Item ## Channel List Item
@@ -6,6 +6,23 @@ Related:
- [Embedded Inline Playback](./embedded-inline-playback.md) - [Embedded Inline Playback](./embedded-inline-playback.md)
## Detail Scroll and Focus
`PortalDetailShellComponent` is the single scroll owner for portal, collection,
M3U movie and offline detail surfaces. It is a named, focusable region with a
native scrollbar and stable gutter. Scrollbars follow the platform's visibility
policy; CSS must not hide them. Content that fits the pane needs no thumb.
On its first render, a browse shell takes focus only if it is still on the
page body or the enclosing workspace `main`; it does not steal focus from a
button, input, dialog or inert surface. Replacing a loading shell can hand off
page focus to the loaded shell, but metadata updates and browse/watch changes
do not refocus it. Initial watch playback keeps its existing focus behavior.
ArrowUp/Down, PageUp/Down, Home/End and Space on the shell scroll natively and
do not reach global player shortcuts. Descendant controls retain their native
keys and Tab order. Entering watch still scrolls to the top; Back and saved
catalog scroll positions retain the existing navigation contract below.
## Summary ## Summary
- Xtream category browsing uses a route-first detail model. - Xtream category browsing uses a route-first detail model.
@@ -1,8 +1,10 @@
@if (items().length > 0) { @if (items().length > 0) {
<div class="nav-list"> <div class="nav-list" id="portal-categories">
@for (item of items(); track $index) { @for (item of items(); track $index) {
<a <button
type="button"
class="nav-item category-item" class="nav-item category-item"
(keydown)="focusLiveChannels($event)"
[attr.data-category-id]="$any(item).category_id ?? item.id" [attr.data-category-id]="$any(item).category_id ?? item.id"
[class.selected]="isSelected(item)" [class.selected]="isSelected(item)"
[class.category-item--selected]="isSelected(item)" [class.category-item--selected]="isSelected(item)"
@@ -25,7 +27,7 @@
{{ $any(item).count }} {{ $any(item).count }}
</div> </div>
} }
</a> </button>
} @empty { } @empty {
<app-playlist-error-view <app-playlist-error-view
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate" [title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
@@ -1,3 +1,4 @@
import { focusLiveChannels } from '@iptvnator/ui/components';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -27,6 +28,8 @@ interface CategoryViewItem {
styleUrls: ['./category-view.component.scss'], styleUrls: ['./category-view.component.scss'],
}) })
export class CategoryViewComponent { export class CategoryViewComponent {
readonly focusLiveChannels = focusLiveChannels;
readonly items = input<CategoryViewItem[]>([]); readonly items = input<CategoryViewItem[]>([]);
readonly selectedCategoryId = input<string | number | null | undefined>(); readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map()); readonly itemCounts = input<Map<number, number>>(new Map());
@@ -1,4 +1,6 @@
<div <div
appChannelScrollFocus
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
class="nav-list app-scrollbar" class="nav-list app-scrollbar"
cdkDropList cdkDropList
[cdkDropListDisabled]="!canDragDrop()" [cdkDropListDisabled]="!canDragDrop()"
@@ -13,15 +15,12 @@
</p> </p>
<p class="empty-hint"> <p class="empty-hint">
{{ {{
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT' 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS_HINT' | translate
| translate
}} }}
</p> </p>
} @else { } @else {
<p class="empty-title"> <p class="empty-title">
{{ {{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate }}
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_TITLE' | translate
}}
</p> </p>
<p class="empty-hint"> <p class="empty-hint">
{{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }} {{ 'WORKSPACE.GLOBAL_FAVORITES.EMPTY_HINT' | translate }}
@@ -48,6 +47,7 @@
[showDetailsContextMenu]="hasChannelContextMenu(ch)" [showDetailsContextMenu]="hasChannelContextMenu(ch)"
(clicked)="onChannelClick(ch)" (clicked)="onChannelClick(ch)"
(activated)="onChannelActivate(ch)" (activated)="onChannelActivate(ch)"
(keyboardActivated)="playbackRequested.emit(ch)"
(favoriteToggled)="onFavoriteToggled(ch)" (favoriteToggled)="onFavoriteToggled(ch)"
(contextMenuRequested)="onChannelContextMenu(ch, $event)" (contextMenuRequested)="onChannelContextMenu(ch, $event)"
/> />
@@ -80,9 +80,7 @@
<button mat-menu-item (click)="removeContextMenuChannel()"> <button mat-menu-item (click)="removeContextMenuChannel()">
<mat-icon>delete</mat-icon> <mat-icon>delete</mat-icon>
<span> <span>
{{ {{ 'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate }}
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
}}
</span> </span>
</button> </button>
} }
@@ -1,3 +1,4 @@
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
import { import {
CdkDragDrop, CdkDragDrop,
DragDropModule, DragDropModule,
@@ -20,10 +21,7 @@ import {
ChannelDetailsDialogComponent, ChannelDetailsDialogComponent,
ChannelListItemComponent, ChannelListItemComponent,
} from '@iptvnator/ui/components'; } from '@iptvnator/ui/components';
import { import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { import {
buildStalkerEpgMappingKey, buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey, buildXtreamEpgMappingKey,
@@ -56,6 +54,7 @@ export type GlobalFavoritesListMode = 'favorites' | 'recent';
styleUrl: './global-favorites-list.component.scss', styleUrl: './global-favorites-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ChannelScrollFocusDirective,
ChannelListItemComponent, ChannelListItemComponent,
DragDropModule, DragDropModule,
MatIconModule, MatIconModule,
@@ -67,7 +67,13 @@
</div> </div>
} }
<div #scrollContainer class="channels-list"> <div
#scrollContainer
class="channels-list app-scrollbar"
appChannelScrollFocus
id="live-channels"
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
>
@if (isInitialChannelsLoading()) { @if (isInitialChannelsLoading()) {
<app-channel-list-skeleton <app-channel-list-skeleton
[count]="9" [count]="9"
@@ -123,6 +129,7 @@
" "
(clicked)="playChannel(item)" (clicked)="playChannel(item)"
(activated)="playChannel(item, true)" (activated)="playChannel(item, true)"
(keyboardActivated)="playChannel(item, true)"
(favoriteToggled)="toggleFavorite(item)" (favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)=" (contextMenuRequested)="
onChannelContextMenu(item, $event) onChannelContextMenu(item, $event)
@@ -1,3 +1,4 @@
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -117,6 +118,7 @@ const FULL_LIST_RENDER_CHUNK = 100;
templateUrl: './stalker-live-stream-layout.component.html', templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: ['./stalker-live-stream-layout.component.scss'], styleUrls: ['./stalker-live-stream-layout.component.scss'],
imports: [ imports: [
ChannelScrollFocusDirective,
AudioPlayerComponent, AudioPlayerComponent,
ChannelListItemComponent, ChannelListItemComponent,
ChannelListSkeletonComponent, ChannelListSkeletonComponent,
@@ -17,8 +17,11 @@
</div> </div>
} @else if (filteredChannels().length > 0) { } @else if (filteredChannels().length > 0) {
<cdk-virtual-scroll-viewport <cdk-virtual-scroll-viewport
appChannelScrollFocus
id="live-channels"
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
[itemSize]="channelItemSize" [itemSize]="channelItemSize"
class="scroll-viewport-portals" class="scroll-viewport-portals app-scrollbar"
> >
<app-channel-list-item <app-channel-list-item
*cdkVirtualFor=" *cdkVirtualFor="
@@ -50,6 +53,7 @@
[isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false" [isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false"
(clicked)="playClicked.emit(item)" (clicked)="playClicked.emit(item)"
(activated)="playbackRequested.emit(item)" (activated)="playbackRequested.emit(item)"
(keyboardActivated)="playbackRequested.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)" (favoriteToggled)="toggleFavorite($event, item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)" (contextMenuRequested)="onChannelContextMenu(item, $event)"
/> />
@@ -410,6 +410,42 @@ describe('PortalChannelsListComponent', () => {
expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth'); expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth');
}); });
it('does not start smooth alignment when a selected row is already visible', () => {
selectedTypeContentLoading.set(false);
selectedChannels.set(
Array.from({ length: 20 }, (_, index) => ({
title: `Channel ${index + 1}`,
xtream_id: index + 1,
}))
);
fixture.detectChanges();
const viewport = fixture.componentInstance.viewport()!;
jest.spyOn(viewport, 'getViewportSize').mockReturnValue(400);
jest.spyOn(viewport, 'measureScrollOffset').mockReturnValue(0);
const scroll = jest.spyOn(viewport, 'scrollToIndex');
selectedItem.set({ xtream_id: 2 });
fixture.detectChanges();
expect(scroll).not.toHaveBeenCalled();
});
it('does not realign after an update to the same selected channel', () => {
selectedTypeContentLoading.set(false);
selectedChannels.set(
Array.from({ length: 20 }, (_, index) => ({
title: `Channel ${index + 1}`,
xtream_id: index + 1,
}))
);
fixture.detectChanges();
const viewport = fixture.componentInstance.viewport()!;
selectedItem.set({ xtream_id: 2 });
fixture.detectChanges();
const scroll = jest.spyOn(viewport, 'scrollToIndex');
selectedItem.set({ xtream_id: 2 });
fixture.detectChanges();
expect(scroll).not.toHaveBeenCalled();
});
it('does not re-scroll the virtual list when the search filter changes', () => { it('does not re-scroll the virtual list when the search filter changes', () => {
const channels = Array.from({ length: 20 }, (_, index) => ({ const channels = Array.from({ length: 20 }, (_, index) => ({
title: `Channel ${index + 1}`, title: `Channel ${index + 1}`,
@@ -1,3 +1,4 @@
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
import { import {
CdkVirtualScrollViewport, CdkVirtualScrollViewport,
ScrollingModule, ScrollingModule,
@@ -75,6 +76,7 @@ interface XtreamCategoryLike {
styleUrls: ['./portal-channels-list.component.scss'], styleUrls: ['./portal-channels-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ChannelScrollFocusDirective,
ChannelListItemComponent, ChannelListItemComponent,
ChannelListSkeletonComponent, ChannelListSkeletonComponent,
MatButtonModule, MatButtonModule,
@@ -157,8 +159,7 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
// run only records the initial value. // run only records the initial value.
let appliedOffsetMinutes: number | null = null; let appliedOffsetMinutes: number | null = null;
effect(() => { effect(() => {
const offsetMinutes = const offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes();
this.settingsStore.resolvedEpgOffsetMinutes();
if (appliedOffsetMinutes === offsetMinutes) { if (appliedOffsetMinutes === offsetMinutes) {
return; return;
} }
@@ -170,12 +171,16 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
untracked(() => this.repickPreviewsForOffsetChange()); untracked(() => this.repickPreviewsForOffsetChange());
}); });
const selectedChannelId = computed(() =>
Number(
(
this.xtreamStore.selectedItem() as XtreamChannelListItem | null
)?.xtream_id
)
);
effect(() => { effect(() => {
const selectedItem = this.xtreamStore.selectedItem(); const selectedId = selectedChannelId();
const viewport = this.viewport(); const viewport = this.viewport();
const selectedId = Number(
(selectedItem as XtreamChannelListItem | null)?.xtream_id
);
if (!viewport || !Number.isFinite(selectedId) || selectedId <= 0) { if (!viewport || !Number.isFinite(selectedId) || selectedId <= 0) {
return; return;
@@ -189,6 +194,17 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
return; return;
} }
const top = viewport.measureScrollOffset();
const rowTop = selectedIndex * this.channelItemSize;
if (
rowTop >= top &&
rowTop + this.channelItemSize <=
top + viewport.getViewportSize()
) {
// Even a smooth scroll to the current offset can cancel the
// user's first keyboard scroll after a pointer selection.
return;
}
viewport.scrollToIndex(selectedIndex, 'smooth'); viewport.scrollToIndex(selectedIndex, 'smooth');
}); });
@@ -529,7 +545,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
// ── Context menu ──────────────────────────────────────────── // ── Context menu ────────────────────────────────────────────
onChannelContextMenu(channel: XtreamChannelListItem, event: MouseEvent): void { onChannelContextMenu(
channel: XtreamChannelListItem,
event: MouseEvent
): void {
this.contextMenuChannel.set(channel); this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({ this.contextMenuPosition.set({
x: `${event.clientX}px`, x: `${event.clientX}px`,
+2
View File
@@ -28,3 +28,5 @@ export * from './lib/vod-sources/vod-sources-chip.component';
export * from './lib/vod-sources/vod-sources-menu.component'; export * from './lib/vod-sources/vod-sources-menu.component';
export * from './lib/watched-badge/watched-badge.component'; export * from './lib/watched-badge/watched-badge.component';
export * from './lib/window-controls/window-controls.component'; export * from './lib/window-controls/window-controls.component';
export * from './lib/channel-scroll-focus/channel-scroll-focus.directive';
@@ -62,6 +62,8 @@
</div> </div>
} @else { } @else {
<cdk-virtual-scroll-viewport <cdk-virtual-scroll-viewport
appChannelScrollFocus
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
[itemSize]="itemSize()" [itemSize]="itemSize()"
class="scroll-viewport app-scrollbar" class="scroll-viewport app-scrollbar"
> >
@@ -90,6 +92,7 @@
" "
(clicked)="onChannelClick(channel)" (clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)" (activated)="onChannelActivate(channel)"
(keyboardActivated)="channelPlaybackRequested.emit(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)" (contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url" [selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true" [showFavoriteButton]="true"
@@ -1,3 +1,4 @@
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
import { ScrollingModule } from '@angular/cdk/scrolling'; import { ScrollingModule } from '@angular/cdk/scrolling';
import { import {
@@ -47,6 +48,7 @@ export type { ChannelEpgMetadata } from '../epg-enrichment.util';
styleUrls: ['./all-channels-view.component.scss'], styleUrls: ['./all-channels-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ChannelScrollFocusDirective,
ChannelListItemComponent, ChannelListItemComponent,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
@@ -13,7 +13,11 @@
@if (isDraggable()) { @if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon> <mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
} }
<div class="channel-content"> <button
type="button"
class="channel-content"
[attr.aria-label]="displayName()"
>
<div class="channel-logo-shell" aria-hidden="true"> <div class="channel-logo-shell" aria-hidden="true">
@if (showLogoFallback()) { @if (showLogoFallback()) {
<mat-icon class="channel-logo-fallback">live_tv</mat-icon> <mat-icon class="channel-logo-fallback">live_tv</mat-icon>
@@ -80,7 +84,7 @@
} }
} }
</div> </div>
</div> </button>
@if ( @if (
(showProgramInfoButton() && showEpg()) || (showProgramInfoButton() && showEpg()) ||
showFavoriteButton() || showFavoriteButton() ||
@@ -61,6 +61,21 @@
} }
.channel-content { .channel-content {
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
app-region: no-drag;
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
border-radius: 4px;
}
min-width: 0; min-width: 0;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -34,6 +34,24 @@ describe('ChannelListItemComponent', () => {
fixture = TestBed.createComponent(ChannelListItemComponent); fixture = TestBed.createComponent(ChannelListItemComponent);
}); });
it('exposes a separate keyboard activation button without nesting row actions', () => {
fixture.componentRef.setInput('name', 'News');
fixture.componentRef.setInput('showFavoriteButton', true);
fixture.detectChanges();
const primary = fixture.nativeElement.querySelector(
'button.channel-content'
);
expect(primary).not.toBeNull();
expect(primary?.querySelector('button')).toBeNull();
const activated = jest.fn();
const clicked = jest.fn();
fixture.componentInstance.keyboardActivated.subscribe(activated);
fixture.componentInstance.clicked.subscribe(clicked);
primary?.click();
expect(activated).toHaveBeenCalledTimes(1);
expect(clicked).not.toHaveBeenCalled();
});
it('formats preview times from timestamp fields when raw strings are offset', () => { it('formats preview times from timestamp fields when raw strings are offset', () => {
const startTimestamp = Math.floor( const startTimestamp = Math.floor(
Date.parse('2026-04-05T05:30:00.000Z') / 1000 Date.parse('2026-04-05T05:30:00.000Z') / 1000
@@ -77,7 +77,10 @@ export class ChannelListItemComponent {
readonly auxActionTooltip = input(''); readonly auxActionTooltip = input('');
readonly clicked = output<void>(); readonly clicked = output<void>();
/** Pointer double click; consumers retain their existing preference gate. */
readonly activated = output<void>(); readonly activated = output<void>();
/** Explicit keyboard playback, independent of the pointer double-click preference. */
readonly keyboardActivated = output<void>();
readonly favoriteToggled = output<MouseEvent>(); readonly favoriteToggled = output<MouseEvent>();
readonly auxActionClicked = output<MouseEvent>(); readonly auxActionClicked = output<MouseEvent>();
readonly contextMenuRequested = output<MouseEvent>(); readonly contextMenuRequested = output<MouseEvent>();
@@ -132,7 +135,11 @@ export class ChannelListItemComponent {
return; return;
} }
this.clicked.emit(); if (event?.detail === 0) {
this.keyboardActivated.emit();
} else {
this.clicked.emit();
}
} }
onDoubleClick(): void { onDoubleClick(): void {
@@ -1,4 +1,6 @@
<div <div
appChannelScrollFocus
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
class="channel-list app-scrollbar" class="channel-list app-scrollbar"
cdkDropList cdkDropList
[cdkDropListDisabled]="hasSearchTerm()" [cdkDropListDisabled]="hasSearchTerm()"
@@ -32,6 +34,7 @@
[showDetailsContextMenu]="true" [showDetailsContextMenu]="true"
(clicked)="onChannelClick(channel)" (clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)" (activated)="onChannelActivate(channel)"
(keyboardActivated)="channelPlaybackRequested.emit(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)" (contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url" [selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true" [showFavoriteButton]="true"
@@ -1,3 +1,4 @@
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
import { import {
CdkDragDrop, CdkDragDrop,
DragDropModule, DragDropModule,
@@ -37,6 +38,7 @@ import { ChannelListItemComponent } from '../channel-list-item/channel-list-item
styleUrls: ['./favorites-view.component.scss'], styleUrls: ['./favorites-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ChannelScrollFocusDirective,
ChannelListItemComponent, ChannelListItemComponent,
DragDropModule, DragDropModule,
MatIconModule, MatIconModule,
@@ -90,7 +92,9 @@ export class FavoritesViewComponent {
y: '0px', y: '0px',
}); });
readonly hasSearchTerm = computed(() => this.searchTerm().trim().length > 0); readonly hasSearchTerm = computed(
() => this.searchTerm().trim().length > 0
);
readonly filteredFavorites = computed(() => { readonly filteredFavorites = computed(() => {
const favorites = this.favorites(); const favorites = this.favorites();
const term = this.searchTerm().trim().toLowerCase(); const term = this.searchTerm().trim().toLowerCase();
@@ -188,7 +192,6 @@ export class FavoritesViewComponent {
}); });
} }
openChannelDetails(): void { openChannelDetails(): void {
const channel = this.contextMenuChannel(); const channel = this.contextMenuChannel();
if (!channel) { if (!channel) {
@@ -243,6 +243,8 @@
</header> </header>
<cdk-virtual-scroll-viewport <cdk-virtual-scroll-viewport
appChannelScrollFocus
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
[itemSize]="itemSize()" [itemSize]="itemSize()"
class="groups-channels-viewport app-scrollbar" class="groups-channels-viewport app-scrollbar"
> >
@@ -280,6 +282,9 @@
" "
(clicked)="onChannelClick(channel)" (clicked)="onChannelClick(channel)"
(activated)="onChannelActivate(channel)" (activated)="onChannelActivate(channel)"
(keyboardActivated)="
channelPlaybackRequested.emit(channel)
"
(contextMenuRequested)=" (contextMenuRequested)="
onChannelContextMenu(channel, $event) onChannelContextMenu(channel, $event)
" "
@@ -1,3 +1,4 @@
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
import { KeyValue, TitleCasePipe } from '@angular/common'; import { KeyValue, TitleCasePipe } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -66,6 +67,7 @@ interface FilteredGroupView {
styleUrls: ['./groups-view.component.scss'], styleUrls: ['./groups-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ChannelScrollFocusDirective,
ChannelListItemComponent, ChannelListItemComponent,
MatButtonModule, MatButtonModule,
MatIconModule, MatIconModule,
@@ -1,4 +1,9 @@
<div class="channel-list app-scrollbar" id="recent-list"> <div
appChannelScrollFocus
[attr.aria-label]="'HOME.PLAYLISTS.CHANNELS' | translate"
class="channel-list app-scrollbar"
id="recent-list"
>
@if (enrichedRecentItems().length > 0) { @if (enrichedRecentItems().length > 0) {
@for ( @for (
item of enrichedRecentItems(); item of enrichedRecentItems();
@@ -10,7 +15,8 @@
i + i +
1 + 1 +
'. ' + '. ' +
(item.channel.name || 'CHANNELS.UNNAMED_CHANNEL' | translate) (item.channel.name || 'CHANNELS.UNNAMED_CHANNEL'
| translate)
" "
[logo]="item.logo" [logo]="item.logo"
[showEpg]="shouldShowEpg()" [showEpg]="shouldShowEpg()"
@@ -26,6 +32,9 @@
" "
(clicked)="onChannelClick(item.channel)" (clicked)="onChannelClick(item.channel)"
(activated)="onChannelActivate(item.channel)" (activated)="onChannelActivate(item.channel)"
(keyboardActivated)="
channelPlaybackRequested.emit(item.channel)
"
(contextMenuRequested)=" (contextMenuRequested)="
onChannelContextMenu(item.channel, $event) onChannelContextMenu(item.channel, $event)
" "
@@ -1,3 +1,4 @@
import { ChannelScrollFocusDirective } from '../../channel-scroll-focus/channel-scroll-focus.directive';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -40,6 +41,7 @@ export interface RecentViewItem {
styleUrls: ['./recent-view.component.scss'], styleUrls: ['./recent-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ChannelScrollFocusDirective,
ChannelListItemComponent, ChannelListItemComponent,
MatIconModule, MatIconModule,
MatMenuModule, MatMenuModule,
@@ -0,0 +1,149 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ChannelScrollFocusDirective,
focusLiveChannels,
} from './channel-scroll-focus.directive';
@Component({
imports: [ChannelScrollFocusDirective],
template: `
<div id="portal-categories">
<button aria-current="true" (keydown)="enter($event)">News</button>
<button (keydown)="enter($event)">Sport</button>
</div>
<div appChannelScrollFocus id="live-channels" aria-label="Channels">
<div class="channel-list-item">
<button class="channel-content">Channel</button>
<button class="favorite-button">Favorite</button>
</div>
<input />
</div>
`,
})
class Host {
readonly enter = focusLiveChannels;
}
describe('Channel scroll focus', () => {
let fixture: ComponentFixture<Host>;
let pane: HTMLElement;
let category: HTMLButtonElement;
let action: HTMLButtonElement;
const key = (element: HTMLElement, key: string, modifiers = {}) => {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...modifiers,
});
element.dispatchEvent(event);
return event;
};
beforeEach(() => {
fixture = TestBed.createComponent(Host);
fixture.detectChanges();
pane = fixture.nativeElement.querySelector('#live-channels');
category = fixture.nativeElement.querySelector(
'#portal-categories button'
);
action = pane.querySelector('.channel-content')!;
pane.checkVisibility = () => true;
category.checkVisibility = () => true;
});
it('transfers focus both ways without clicks or scroll resets', () => {
const click = jest.fn();
pane.addEventListener('click', click);
pane.scrollTop = 400;
category.focus();
expect(key(category, 'ArrowRight').defaultPrevented).toBe(true);
expect(document.activeElement).toBe(pane);
expect(pane.scrollTop).toBe(400);
expect(key(pane, 'ArrowLeft').defaultPrevented).toBe(true);
expect(document.activeElement).toBe(category);
expect(click).not.toHaveBeenCalled();
});
it('does not transfer from unselected categories, modified keys or into hidden/inert panes', () => {
const other = category.nextElementSibling as HTMLElement;
other.focus();
key(other, 'ArrowRight');
expect(document.activeElement).toBe(other);
category.focus();
key(category, 'ArrowRight', { ctrlKey: true });
expect(document.activeElement).toBe(category);
pane.checkVisibility = () => false;
key(category, 'ArrowRight');
expect(document.activeElement).toBe(category);
pane.checkVisibility = () => true;
pane.classList.add('sidebar-collapsed');
key(category, 'ArrowRight');
expect(document.activeElement).toBe(category);
pane.classList.remove('sidebar-collapsed');
pane.setAttribute('inert', '');
key(category, 'ArrowRight');
expect(document.activeElement).toBe(category);
});
it('keeps pointer selection in the scroll owner but preserves keyboard activation and row actions', () => {
action.focus();
action.dispatchEvent(
new MouseEvent('click', { bubbles: true, detail: 1 })
);
expect(document.activeElement).toBe(pane);
action.focus();
action.click();
expect(document.activeElement).toBe(action);
const favorite =
pane.querySelector<HTMLButtonElement>('.favorite-button')!;
favorite.focus();
favorite.dispatchEvent(
new MouseEvent('click', { bubbles: true, detail: 1 })
);
expect(document.activeElement).toBe(favorite);
});
it.each(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'])(
'retains native %s scrolling when a virtual row is recycled',
(name) => {
const globalKey = jest.fn();
document.addEventListener('keydown', globalKey);
try {
action.focus();
const event = key(action, name);
expect(event.defaultPrevented).toBe(false);
expect(document.activeElement).toBe(pane);
action.remove();
expect(document.activeElement).toBe(pane);
expect(globalKey).not.toHaveBeenCalled();
} finally {
document.removeEventListener('keydown', globalKey);
}
}
);
it('leaves Tab, Shift+Tab, Enter, Space on buttons and input keys alone', () => {
for (const name of ['Tab', 'Enter', ' ']) {
action.focus();
expect(key(action, name).defaultPrevented).toBe(false);
expect(document.activeElement).toBe(action);
}
expect(key(action, 'Tab', { shiftKey: true }).defaultPrevented).toBe(
false
);
const input = pane.querySelector('input')!;
input.focus();
key(input, 'ArrowLeft');
key(input, 'ArrowDown');
expect(document.activeElement).toBe(input);
});
it('does not refocus when rows update asynchronously', () => {
category.focus();
pane.append(document.createElement('button'));
fixture.detectChanges();
expect(document.activeElement).toBe(category);
});
});
@@ -0,0 +1,95 @@
import { Directive, ElementRef, inject } from '@angular/core';
const HIDDEN_PANE = '[inert], .sidebar-collapsed, .context-panel--collapsed';
const SCROLL_KEYS = new Set([
'ArrowUp',
'ArrowDown',
'PageUp',
'PageDown',
'Home',
'End',
' ',
]);
function unmodified(event: KeyboardEvent): boolean {
return (
!event.defaultPrevented &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
);
}
/** Enter the visible live pane without selecting a channel or starting playback. */
export function focusLiveChannels(event: KeyboardEvent): void {
if (event.key !== 'ArrowRight' || !unmodified(event)) return;
const category = event.currentTarget as HTMLElement;
if (category.getAttribute('aria-current') !== 'true') return;
const pane = category.ownerDocument.getElementById('live-channels');
if (
!pane?.checkVisibility({ checkVisibilityCSS: true }) ||
pane.closest(HIDDEN_PANE)
)
return;
event.preventDefault();
event.stopPropagation();
pane.focus({ preventScroll: true });
}
/** Native scroll ownership survives CDK row recycling; row actions remain buttons. */
@Directive({
selector: '[appChannelScrollFocus]',
host: {
tabindex: '0',
role: 'region',
class: 'channel-scroll-focus',
'(click)': 'onClick($event)',
'(keydown)': 'onKeydown($event)',
},
})
export class ChannelScrollFocusDirective {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
onClick(event: MouseEvent): void {
const target = event.target as HTMLElement;
if (event.detail <= 0 || !target.closest('.channel-list-item')) return;
if (
target.closest(
'button:not(.channel-content), a, input, select, textarea'
)
)
return;
this.host.nativeElement.focus({ preventScroll: true });
}
onKeydown(event: KeyboardEvent): void {
const pane = this.host.nativeElement;
const target = event.target as HTMLElement;
if (!unmodified(event) || pane.closest(HIDDEN_PANE)) return;
const rowAction = target.matches('button.channel-content');
if (target !== pane && !rowAction) return;
if (event.key === 'ArrowLeft' && pane.id === 'live-channels') {
const category = pane.ownerDocument.querySelector<HTMLElement>(
'#portal-categories [aria-current="true"]'
);
if (
!category?.checkVisibility({ checkVisibilityCSS: true }) ||
category.closest(HIDDEN_PANE)
)
return;
event.preventDefault();
event.stopPropagation();
category.focus({ preventScroll: true });
} else if (
SCROLL_KEYS.has(event.key) &&
!(rowAction && event.key === ' ')
) {
// Do not preventDefault: Chromium owns scroll distance, repetition,
// PageUp/PageDown and Shift+Space. Only silence global player keys.
event.stopPropagation();
pane.focus({ preventScroll: true });
}
}
}
@@ -21,11 +21,12 @@
background: var(--surface-bg); background: var(--surface-bg);
color: var(--text-primary); color: var(--text-primary);
// Hide scrollbar but keep functionality (parity with the old hero scroll) scrollbar-gutter: stable;
scrollbar-width: none; scrollbar-width: auto;
-ms-overflow-style: none;
&::-webkit-scrollbar { &:focus-visible {
display: none; outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
} }
} }
@@ -64,6 +64,42 @@ describe('PortalDetailShellComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('makes the scroll owner a named keyboard region and focuses it once', async () => {
await fixture.whenStable();
const shell = query('app-portal-detail-shell')!;
expect(shell.tabIndex).toBe(0);
expect(shell.getAttribute('aria-label')).toBe('Show Title');
expect(document.activeElement).toBe(shell);
const play = query('.play-btn')!;
play.focus();
host.playbackActive.set(true);
fixture.detectChanges();
await fixture.whenStable();
expect(document.activeElement).not.toBe(shell);
});
it('keeps native scrolling on the shell without forwarding keys to the player', () => {
const shell = query('app-portal-detail-shell')!;
const globalKey = jest.fn();
document.addEventListener('keydown', globalKey);
try {
const event = new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
});
shell.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(globalKey).not.toHaveBeenCalled();
query('.play-btn')!.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true })
);
expect(globalKey).toHaveBeenCalledTimes(1);
} finally {
document.removeEventListener('keydown', globalKey);
}
});
it('renders hero with stamped tags/meta/actions in browse state', () => { it('renders hero with stamped tags/meta/actions in browse state', () => {
expect(query('.shell__hero--collapsed')).toBeNull(); expect(query('.shell__hero--collapsed')).toBeNull();
expect(query('app-content-hero')).toBeTruthy(); expect(query('app-content-hero')).toBeTruthy();
@@ -1,5 +1,6 @@
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { import {
afterNextRender,
Component, Component,
ElementRef, ElementRef,
computed, computed,
@@ -37,6 +38,10 @@ import {
templateUrl: './portal-detail-shell.component.html', templateUrl: './portal-detail-shell.component.html',
styleUrls: ['./portal-detail-shell.component.scss'], styleUrls: ['./portal-detail-shell.component.scss'],
host: { host: {
tabindex: '0',
role: 'region',
'[attr.aria-label]': 'title() || backLabel()',
'(keydown)': 'onScrollKey($event)',
'[class.shell-host--watch]': 'isWatch()', '[class.shell-host--watch]': 'isWatch()',
'(document:keydown.escape)': 'onEscape($event)', '(document:keydown.escape)': 'onEscape($event)',
}, },
@@ -67,6 +72,18 @@ export class PortalDetailShellComponent {
readonly isWatch = computed(() => this.playbackActive()); readonly isWatch = computed(() => this.playbackActive());
constructor() { constructor() {
afterNextRender(() => {
const element = this.host.nativeElement;
const active = element.ownerDocument.activeElement;
if (
!this.playbackActive() &&
!element.closest('[inert]') &&
(active === element.ownerDocument.body ||
active === element.closest('main'))
) {
element.focus({ preventScroll: true });
}
});
let wasWatch = false; let wasWatch = false;
effect(() => { effect(() => {
const watch = this.isWatch(); const watch = this.isWatch();
@@ -77,6 +94,28 @@ export class PortalDetailShellComponent {
}); });
} }
onScrollKey(event: KeyboardEvent): void {
if (
event.target === this.host.nativeElement &&
[
'ArrowUp',
'ArrowDown',
'PageUp',
'PageDown',
'Home',
'End',
' ',
].includes(event.key) &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey
) {
// Keep native page scrolling; a mounted player's global shortcuts
// must not turn this into volume adjustment or pause.
event.stopPropagation();
}
}
onEscape(event: Event): void { onEscape(event: Event): void {
if (!this.playbackActive()) return; if (!this.playbackActive()) return;
if (event.defaultPrevented) return; if (event.defaultPrevented) return;
@@ -30,6 +30,7 @@ body.resizing-active {
// Resize handle base styles // Resize handle base styles
.resize-handle { .resize-handle {
app-region: no-drag;
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
+4
View File
@@ -23,6 +23,8 @@
.sidebar { .sidebar {
// Width controlled by the resizable directive at runtime // Width controlled by the resizable directive at runtime
width: 400px; width: 400px;
box-sizing: border-box;
padding-inline-end: 8px;
min-width: 250px; min-width: 250px;
max-width: 600px; max-width: 600px;
border-right: 1px solid var(--app-separator); border-right: 1px solid var(--app-separator);
@@ -41,6 +43,7 @@
// Beats the inline width set by the resizable directive while // Beats the inline width set by the resizable directive while
// preserving it so uncollapsing snaps back to the previous size. // preserving it so uncollapsing snaps back to the previous size.
width: 0 !important; width: 0 !important;
padding-inline-end: 0;
min-width: 0 !important; min-width: 0 !important;
border-right-color: transparent; border-right-color: transparent;
} }
@@ -190,6 +193,7 @@
} }
.sidebar { .sidebar {
padding-inline-end: 0;
width: 100% !important; width: 100% !important;
min-width: 0 !important; min-width: 0 !important;
max-width: none; max-width: none;
@@ -1,5 +1,5 @@
@if (items().length > 0) { @if (items().length > 0) {
<div class="nav-list"> <div class="nav-list" id="portal-categories">
@if (statusText()) { @if (statusText()) {
<div class="context-inline-status"> <div class="context-inline-status">
{{ statusText() }} {{ statusText() }}
@@ -9,6 +9,7 @@
<button <button
type="button" type="button"
class="nav-item category-item" class="nav-item category-item"
(keydown)="focusLiveChannels($event)"
[attr.data-category-id]=" [attr.data-category-id]="
$any(item).category_id ?? $any(item).xtream_id ?? item.id $any(item).category_id ?? $any(item).xtream_id ?? item.id
" "
@@ -1,3 +1,4 @@
import { focusLiveChannels } from '@iptvnator/ui/components';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -27,6 +28,8 @@ interface WorkspaceCategoryViewItem {
styleUrl: './workspace-context-category-view.component.scss', styleUrl: './workspace-context-category-view.component.scss',
}) })
export class WorkspaceContextCategoryViewComponent { export class WorkspaceContextCategoryViewComponent {
readonly focusLiveChannels = focusLiveChannels;
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]); readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
readonly selectedCategoryId = input<string | number | null | undefined>(); readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map()); readonly itemCounts = input<Map<number, number>>(new Map());