Merge origin/master into the programme guide branch

Adopts the compact icon-only timeline toolbar (#1557): the Guide button is
now an icon-only `epg-timeline__iconbtn` with its label in the tooltip and
aria-label, so the unused EPG.TIMELINE.GUIDE key is dropped from all locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:59:04 +02:00
commit 49fdd40c9d
123 files changed
+5976 -474

No files matched your search

@@ -58,7 +58,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
let liveSidebarService: LiveLayoutSidebarStateService;
beforeEach(async () => {
localStorage.removeItem('live-sidebar-state');
localStorage.removeItem('live-sidebar-state:portal');
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
@@ -99,7 +99,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
fixture = TestBed.createComponent(WorkspaceShellContextSidebarComponent);
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
liveSidebarService.setState('expanded');
liveSidebarService.setState('portal', 'expanded');
});
it('closes the phone drawer from its always-available close button', () => {
@@ -170,7 +170,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('collapses the categories rail when the shared service is collapsed on a live route', () => {
setupLiveCategory('live');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -184,7 +184,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('also collapses on the Stalker itv section', () => {
setupLiveCategory('itv');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -198,7 +198,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('does not collapse the categories rail on non-live sections', () => {
setupLiveCategory('vod');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -70,7 +70,7 @@ export class WorkspaceShellContextSidebarComponent {
readonly isContextPanelCollapsed = computed(
() =>
this.isLiveCategoryRoute() &&
this.liveSidebarStateService.isCollapsed()
this.liveSidebarStateService.isCollapsedFor('portal')()
);
closeDrawer(): void {
@@ -115,6 +115,22 @@
<mat-icon>add</mat-icon>
</button>
@if (!isSettingsRoute()) {
@if (headerSidebarToggle(); as sidebarToggle) {
<button
type="button"
class="header-shortcut header-sidebar-toggle"
[class.header-sidebar-toggle--collapsed]="
!sidebarToggle.expanded
"
mat-icon-button
(click)="onHeaderSidebarToggleRequested()"
[matTooltip]="sidebarToggle.tooltip"
[attr.aria-label]="sidebarToggle.ariaLabel"
[attr.aria-pressed]="sidebarToggle.expanded"
>
<mat-icon>view_sidebar</mat-icon>
</button>
}
@if (headerShortcut(); as action) {
<button
type="button"
@@ -209,6 +209,12 @@
}
}
// The rail is visible most of the time, so the toggle stays neutral then and
// only tints once the rail is hidden: the exception is what deserves the cue.
.header-sidebar-toggle--collapsed {
color: var(--mat-sys-primary);
}
.header-actions--leading {
margin-left: -4px;
}
@@ -392,6 +398,13 @@
.shortcuts-trigger {
display: none;
}
// At the phone breakpoint the live rail is a bottom drawer with its
// own toggle, and the header has no room left for another permanent
// icon: it already holds the drawer toggle, switcher, search and Add.
.header-sidebar-toggle {
display: none;
}
}
}
@@ -158,6 +158,71 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the live rail toggle when the route has no rail', () => {
expect(
fixture.nativeElement.querySelector('.header-sidebar-toggle')
).toBeNull();
});
it('renders the live rail toggle in both rail states and emits toggle requests', () => {
const requested = jest.fn();
component.headerSidebarToggleRequested.subscribe(requested);
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: true,
tooltip: 'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP',
ariaLabel: 'LAYOUT.HIDE_CHANNELS_LIST',
});
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'.header-sidebar-toggle'
);
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.getAttribute('aria-label')).toBe(
'LAYOUT.HIDE_CHANNELS_LIST'
);
expect(button.classList).not.toContain(
'header-sidebar-toggle--collapsed'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
// The control stays in place once the rail is hidden; only its
// pressed state, label and tint change.
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: false,
tooltip: 'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP',
ariaLabel: 'LAYOUT.SHOW_CHANNELS_LIST',
});
fixture.detectChanges();
const collapsedButton: HTMLButtonElement =
fixture.nativeElement.querySelector('.header-sidebar-toggle');
expect(collapsedButton).toBe(button);
expect(collapsedButton.getAttribute('aria-pressed')).toBe('false');
expect(collapsedButton.getAttribute('aria-label')).toBe(
'LAYOUT.SHOW_CHANNELS_LIST'
);
expect(collapsedButton.classList).toContain(
'header-sidebar-toggle--collapsed'
);
});
it('hides the live rail toggle on the settings route', () => {
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: true,
tooltip: 'tooltip',
ariaLabel: 'aria',
});
fixture.componentRef.setInput('isSettingsRoute', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.header-sidebar-toggle')
).toBeNull();
});
it('renders scope and status chips when search metadata is provided', () => {
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
fixture.componentRef.setInput(
@@ -14,7 +14,10 @@ import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shell-constants';
import {
WorkspaceHeaderBulkAction,
WorkspaceHeaderSidebarToggle,
} from '../../services/helpers/workspace-shell-constants';
@Component({
selector: 'app-workspace-shell-header',
@@ -54,6 +57,9 @@ export class WorkspaceShellHeaderComponent {
readonly searchStatusLabel = input('');
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<WorkspaceHeaderSidebarToggle | null>(
null
);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
@@ -101,6 +107,7 @@ export class WorkspaceShellHeaderComponent {
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
@@ -167,6 +174,10 @@ export class WorkspaceShellHeaderComponent {
this.headerBulkActionRequested.emit();
}
onHeaderSidebarToggleRequested(): void {
this.headerSidebarToggleRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
@@ -43,3 +43,15 @@ export interface WorkspaceHeaderBulkAction {
ariaLabel: string;
disabled: boolean;
}
/**
* Header toggle for the live-channel rail of the current route. It exists in
* both rail states so the control never moves: inside the rail the chevron
* disappears together with the rail it hides.
*/
export interface WorkspaceHeaderSidebarToggle {
/** True while the rail is visible; drives `aria-pressed`. */
expanded: boolean;
tooltip: string;
ariaLabel: string;
}
@@ -10,6 +10,8 @@ import {
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/ui';
import {
LiveLayoutSidebarStateService,
resolveRouteLiveSidebarSurface,
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
@@ -30,6 +32,7 @@ import {
CLEAR_RECENTLY_VIEWED_ARIA,
CLEAR_RECENTLY_VIEWED_TOOLTIP,
WorkspaceHeaderBulkAction,
WorkspaceHeaderSidebarToggle,
} from './helpers/workspace-shell-constants';
import { bumpRefreshQueryParam } from './helpers/workspace-shell-route-utils';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
@@ -48,6 +51,7 @@ export class WorkspaceShellHeaderService {
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly liveSidebar = inject(LiveLayoutSidebarStateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -75,6 +79,38 @@ export class WorkspaceShellHeaderService {
return action;
});
/**
* The live rail of the current route, when the route renders one itself.
* The header toggle needs a rail that exists in both states; collection
* pages keep their own toggle beside the live/movies/series switch.
*/
private readonly liveSidebarSurface = computed(() =>
resolveRouteLiveSidebarSurface(
this.routeState.currentContext()?.provider,
this.routeState.currentSection()
)
);
readonly headerSidebarToggle = computed<WorkspaceHeaderSidebarToggle | null>(
() => {
this.languageTick();
const surface = this.liveSidebarSurface();
if (!surface) {
return null;
}
const collapsed = this.liveSidebar.isCollapsedFor(surface)();
return {
expanded: !collapsed,
tooltip: this.translateText('LAYOUT.TOGGLE_SIDEBAR_TOOLTIP'),
ariaLabel: this.translateText(
collapsed
? 'LAYOUT.SHOW_CHANNELS_LIST'
: 'LAYOUT.HIDE_CHANNELS_LIST'
),
};
}
);
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.routeState.activePlaylist())
);
@@ -219,6 +255,13 @@ export class WorkspaceShellHeaderService {
}
}
toggleLiveSidebar(): void {
const surface = this.liveSidebarSurface();
if (surface) {
this.liveSidebar.toggle(surface);
}
}
navigateToGlobalFavorites(): void {
void this.router.navigate(['/workspace/global-favorites']);
}
@@ -127,6 +127,7 @@ export class WorkspaceShellFacade {
readonly canRefreshPlaylist = this.header.canRefreshPlaylist;
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
readonly headerBulkAction = this.header.headerBulkAction;
readonly headerSidebarToggle = this.header.headerSidebarToggle;
readonly playlistSubtitle = this.header.playlistSubtitle;
readonly activeDownloadsCount = computed(() =>
this.supportsDownloads ? this.downloadsService.activeCount() : 0
@@ -205,6 +206,10 @@ export class WorkspaceShellFacade {
return this.header.runHeaderBulkAction();
}
toggleLiveSidebar(): void {
this.header.toggleLiveSidebar();
}
navigateToGlobalFavorites(): void {
this.header.navigateToGlobalFavorites();
}
@@ -39,6 +39,7 @@
[searchStatusLabel]="facade.searchStatusLabel()"
[headerShortcut]="facade.headerShortcut()"
[headerBulkAction]="facade.headerBulkAction()"
[headerSidebarToggle]="facade.headerSidebarToggle()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
@@ -59,6 +60,7 @@
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
@@ -64,6 +64,7 @@ class MockWorkspaceShellHeaderComponent {
readonly contextDrawerToggleAriaKey = input('');
readonly contextDrawerTooltipKey = input('');
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<unknown>(null);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
@@ -73,6 +74,7 @@ class MockWorkspaceShellHeaderComponent {
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
@@ -162,6 +164,8 @@ class MockWorkspaceShellFacade {
readonly isDownloadsView = signal(false);
readonly activeDownloadsCount = signal(3);
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = signal(null);
toggleLiveSidebar = jest.fn();
readonly showContextPanel = signal(true);
readonly hasContextPanelContent = signal(true);
readonly contextDrawerLabelKeys = signal({