fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones

Codex review follow-ups on #1555:

- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
  collection page had no handler; only the routed M3U/Xtream/Stalker live
  layouts did. The page now toggles the collection surface while its live
  tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
  switcher, search and Add; the live rail is a bottom drawer with its own
  toggle there, so the header rail toggle is hidden below 640px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 10:37:39 +02:00
1 parent ebfab57779
commit 71e8446034
5 files changed
+108 -11

No files matched your search

+10 -3
View File
@@ -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
@@ -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
@@ -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<void>((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;
}
@@ -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<HTMLElement>);
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);
@@ -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;
}
}
}