diff --git a/apps/web-e2e/src/mobile-layout.e2e.ts b/apps/web-e2e/src/mobile-layout.e2e.ts index 71c1be549..c0535cdad 100644 --- a/apps/web-e2e/src/mobile-layout.e2e.ts +++ b/apps/web-e2e/src/mobile-layout.e2e.ts @@ -134,9 +134,7 @@ test.describe('portrait phone 375x812', () => { // sections; now the list scrolls and ends above the footer. const listBox = await boxOf(panel.locator('.settings-sections-list')); const footerBox = await boxOf(footer); - expect(listBox.y + listBox.height).toBeLessThanOrEqual( - footerBox.y + 1 - ); + expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1); // Tapping the backdrop (right of the drawer) closes it. await page @@ -216,7 +214,16 @@ test.describe('xtream portal routes on a phone', () => { const sidebar = page.locator('app-live-stream-layout .sidebar'); await expect(sidebar).toBeVisible(); + // The workspace header carries the live rail toggle at desktop + // width only; on the phone the rail is a bottom drawer with its own + // toggle and the header has no room for another permanent icon. + const headerRailToggle = page.locator( + 'app-workspace-shell-header .header-sidebar-toggle' + ); + await expect(headerRailToggle).toBeVisible(); + await page.setViewportSize(LANDSCAPE_PHONE); + await expect(headerRailToggle).toBeHidden(); // The sidebar stacks at full width above the player. await expect diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 931e23a8f..546ac4c91 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -310,6 +310,8 @@ remain local when the meaning is explicit. exception that deserves the cue. Collection pages are deliberately excluded: only the page knows whether its live tab, and therefore the rail, is on screen, so its own header toggle beside the content switch stays the owner. + At the phone breakpoint (≤640px) the header toggle is hidden: the rail is a + bottom drawer there with its own toggle and the header has no spare width. - While the rail is collapsed and nothing is playing, every live host renders `app-channel-list-hidden-state` (`@iptvnator/portal/shared/ui`) instead of the "select a channel" empty state: a title that says the list is hidden, a diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index 3a11e3eb0..e25f799de 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -20,12 +20,17 @@ import { ScopeToggleService, UnifiedCollectionItem, WorkspaceViewCommandService, + LiveLayoutSidebarStateService, + liveSidebarStateStorageKey, } from '@iptvnator/portal/shared/util'; import { UnifiedFavoritesDataService, UnifiedRecentDataService, } from '@iptvnator/portal/shared/data-access'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; @@ -508,19 +513,70 @@ describe('UnifiedCollectionPageComponent', () => { await fixture.whenStable(); expect(favoritesData.removeFavorite).toHaveBeenCalledWith(recentItem); - expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid)) - .toBe(false); + expect( + fixture.componentInstance.favoriteUidSet().has(recentItem.uid) + ).toBe(false); expect(fixture.componentInstance.allItems()).toEqual([recentItem]); liveTab.favoriteToggled.emit(recentItem); await fixture.whenStable(); expect(favoritesData.addFavorite).toHaveBeenCalledWith(recentItem); - expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid)) - .toBe(true); + expect( + fixture.componentInstance.favoriteUidSet().has(recentItem.uid) + ).toBe(true); expect(fixture.componentInstance.allItems()).toEqual([recentItem]); }); + it('toggles the collection live rail with Cmd/Ctrl+B only while the live tab is on screen', async () => { + const recentItem = { + uid: 'm3u::playlist-1::https://example.com/one.m3u8', + name: 'Recent One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/one.m3u8', + } satisfies UnifiedCollectionItem; + recentData.getRecentItems.mockResolvedValueOnce([recentItem]); + const sidebarState = TestBed.inject(LiveLayoutSidebarStateService); + sidebarState.setState('collection', 'expanded'); + + fixture.componentRef.setInput('mode', 'recent'); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + fixture.componentInstance.isLoading.set(false); + fixture.componentInstance.selectedContentType.set('live'); + fixture.componentInstance.allItems.set([recentItem]); + fixture.detectChanges(); + + const press = () => { + const event = new KeyboardEvent('keydown', { + key: 'b', + ctrlKey: true, + cancelable: true, + bubbles: true, + }); + document.dispatchEvent(event); + return event; + }; + + expect(press().defaultPrevented).toBe(true); + expect(sidebarState.isCollapsedFor('collection')()).toBe(true); + expect(sidebarState.isCollapsedFor('m3u')()).toBe(false); + + // Not on the movies grid: there is no rail to hide there. + fixture.componentInstance.selectedContentType.set('movie'); + fixture.detectChanges(); + expect(press().defaultPrevented).toBe(false); + expect(sidebarState.isCollapsedFor('collection')()).toBe(true); + + sidebarState.setState('collection', 'expanded'); + localStorage.removeItem(liveSidebarStateStorageKey('collection')); + }); + it('removes one recent live row through the live tab remove event', async () => { const recentItems = [ { @@ -827,9 +883,7 @@ describe('UnifiedCollectionPageComponent', () => { }, } ); - expect( - fixture.componentInstance.selectedDetailItem() - ).toBeNull(); + expect(fixture.componentInstance.selectedDetailItem()).toBeNull(); } finally { router.url = originalUrl; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index fafeeadca..139d81699 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -7,6 +7,8 @@ import { contentChild, DestroyRef, effect, + ElementRef, + HostListener, inject, linkedSignal, input, @@ -38,6 +40,7 @@ import { getOpenLiveCollectionItemState, getUnifiedCollectionNavigation, isWorkspaceLayoutRoute, + isTypingInInput, LiveLayoutSidebarStateService, OPEN_COLLECTION_DETAIL_STATE_KEY, OPEN_LIVE_COLLECTION_ITEM_STATE_KEY, @@ -96,6 +99,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { private readonly router = inject(Router); private readonly store = inject(Store); private readonly destroyRef = inject(DestroyRef); + private readonly hostElement = inject(ElementRef); private readonly scopeService = inject(ScopeToggleService); private readonly favoritesData = inject(UnifiedFavoritesDataService); private readonly recentData = inject(UnifiedRecentDataService); @@ -473,6 +477,29 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { this.liveSidebarStateService.toggle('collection'); } + /** + * Cmd/Ctrl+B mirrors the routed live layouts (M3U, Xtream, Stalker): the + * hidden-list state advertises the shortcut, so the collection live tab + * must honour it too. Only while that tab, and therefore the rail, is on + * screen; the movies/series grids have nothing to hide. + */ + @HostListener('document:keydown', ['$event']) + handleSidebarShortcut(event: KeyboardEvent): void { + if ( + this.showSidebarToggle() && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === 'b' && + !isTypingInInput(event) && + // Behind the workspace's phone context drawer the route content + // is inert; this document-level listener still fires, so it + // opts out itself instead of toggling an obscured rail. + !this.hostElement.nativeElement.closest('[inert]') + ) { + event.preventDefault(); + this.toggleSidebar(); + } + } + setFavSortMode(mode: FavoritesChannelSortMode): void { this.favSortMode.set(mode); persistFavoritesChannelSortMode(mode); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss index 59e460762..64d98a8bb 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss @@ -398,5 +398,12 @@ .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; + } } }