mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(live-tv): keep a hidden channel list discoverable and scoped per surface
The second report in #1458 ("all channels disappear after clearing the playback history, reset does not bring them back") was not data loss: the history write never touches playlist items. The reporter's screenshot shows a collapsed channel rail, a state persisted under one localStorage key shared by the M3U player, the Xtream/Stalker live layouts and the favorites/recent live tab. It survived restart, "Remove all playlists" and re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B. - LiveLayoutSidebarStateService keeps the state per surface (m3u / portal / collection) under live-sidebar-state:<surface>; the M3U player now goes through the service instead of its own signal. The legacy shared key is forgotten on startup and never read, so the update itself restores the list for everyone who got stuck. - The workspace header renders a view_sidebar toggle on every route that renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so the control exists in both states instead of disappearing with the rail. Collection pages keep their own toggle beside the content switch. - While the rail is collapsed and nothing plays, every live host shows app-channel-list-hidden-state (title, shortcut hint, full-size "Show channels list" button) instead of asking to pick from a list that is not on screen. app-portal-empty-state gained optional hint/action inputs. - New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales. Tests: service, empty-state, hidden-state and header component specs, a separate video-player-sidebar spec (the main M3U spec sits at the test line budget), and an Electron E2E covering history clearing, restore via button/header/shortcut across restart and re-import, per-surface scoping against an Xtream portal, and legacy-key cleanup. Refs #1458 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a2f6121f8
commit
ebf34e5dcb
60 files changed
+1568
-111
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"
|
||||
|
||||
+6
@@ -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;
|
||||
}
|
||||
|
||||
+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