mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
1 parent
ebfab57779
commit
71e8446034
5 files changed
+108
-11
No files matched your search
@@ -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
|
||||
|
||||
+62
-8
@@ -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;
|
||||
}
|
||||
|
||||
+27
@@ -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);
|
||||
|
||||
+7
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user