mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
commit
49fdd40c9d
123 files changed
+5976
-474
No files matched your search
+5
-5
@@ -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(
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export class WorkspaceShellContextSidebarComponent {
|
||||
readonly isContextPanelCollapsed = computed(
|
||||
() =>
|
||||
this.isLiveCategoryRoute() &&
|
||||
this.liveSidebarStateService.isCollapsed()
|
||||
this.liveSidebarStateService.isCollapsedFor('portal')()
|
||||
);
|
||||
|
||||
closeDrawer(): void {
|
||||
|
||||
+16
@@ -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"
|
||||
|
||||
+13
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
@@ -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(
|
||||
|
||||
+12
-1
@@ -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();
|
||||
}
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
+43
@@ -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']);
|
||||
}
|
||||
|
||||
+5
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user