Merge remote-tracking branch 'origin/master' into claude/analyze-second-bug-907512

# Conflicts:
#	libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
#	libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts
This commit is contained in:
4gray committed 2026-09-06 08:56:39 +02:00
commit 11414f0b94
626 files changed
+41521 -5239

No files matched your search

@@ -1,5 +1,5 @@
@if (items().length > 0) {
<div class="nav-list">
<div class="nav-list" id="portal-categories">
@if (statusText()) {
<div class="context-inline-status">
{{ statusText() }}
@@ -9,6 +9,7 @@
<button
type="button"
class="nav-item category-item"
(keydown)="focusLiveChannels($event)"
[attr.data-category-id]="
$any(item).category_id ?? $any(item).xtream_id ?? item.id
"
@@ -1,3 +1,4 @@
import { focusLiveChannels } from '@iptvnator/ui/components';
import {
ChangeDetectionStrategy,
Component,
@@ -27,6 +28,8 @@ interface WorkspaceCategoryViewItem {
styleUrl: './workspace-context-category-view.component.scss',
})
export class WorkspaceContextCategoryViewComponent {
readonly focusLiveChannels = focusLiveChannels;
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map());
@@ -534,7 +534,7 @@ describe('WorkspaceContextPanelComponent', () => {
);
});
it('labels Stalker radio categories and keeps category selection in the radio layout', () => {
it('labels Stalker radio categories and keeps the playing station on a category click', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
@@ -558,10 +558,72 @@ describe('WorkspaceContextPanelComponent', () => {
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith('*');
expect(stalkerStore.setPage).toHaveBeenCalledWith(0);
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
// The radio layout renders its audio player only while the store has
// a selected item — clearing it here would silence the station.
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('preserves the playing Stalker ITV channel when switching live categories', () => {
// Regression: the click handler used to clearSelectedItem() for every
// Stalker section. The live layout gates its player on selectedItem,
// so a category switch in the sidebar stopped a channel the user never
// switched away from — unlike Xtream live (#936) and M3U groups.
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'itv');
stalkerStore.getCategoryResource.set([
{ category_id: '*', category_name: 'All channels' },
{ category_id: '7', category_name: 'Sports' },
]);
fixture.detectChanges();
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
categoryButtons[1]?.click();
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith('7');
expect(stalkerStore.setPage).toHaveBeenCalledWith(0);
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
it('still closes the open Stalker detail when switching VOD categories', () => {
// VOD/series category clicks navigate to a list route, so the open
// detail must be dropped there — the live exemption is deliberate and
// narrow.
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'vod');
stalkerStore.getCategoryResource.set([
{ category_id: '*', category_name: 'All movies' },
{ category_id: '7', category_name: 'Action' },
]);
fixture.detectChanges();
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
categoryButtons[1]?.click();
expect(stalkerStore.setSelectedCategory).toHaveBeenCalledWith('7');
expect(stalkerStore.clearSelectedItem).toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'stalker',
'stalker-1',
'vod',
'7',
]);
});
describe('Stalker category error description', () => {
afterEach(() => {
stalkerStore.isCategoryResourceFailed.set(false as never);
@@ -458,12 +458,18 @@ export class WorkspaceContextPanelComponent {
this.contextDrawer?.close();
this.stalkerStore.setSelectedCategory(categoryId);
this.stalkerStore.setPage(0);
this.stalkerStore.clearSelectedItem();
if (section === 'itv' || section === 'radio') {
// Live TV / radio: the category only re-filters the channel
// sidebar, the selected channel keeps playing (Xtream parity,
// #936). The live layout gates its player on `selectedItem`, so
// clearing it here would tear the player down for a channel the
// user never switched away from.
return;
}
this.stalkerStore.clearSelectedItem();
if (categoryId === '*') {
this.router.navigate([
'/workspace',
@@ -92,6 +92,93 @@ describe('WorkspaceKeyboardShortcutsService', () => {
input.remove();
});
describe('F11 window fullscreen toggle', () => {
const testWindow = window as unknown as {
electron?: Record<string, unknown>;
};
let toggleFullScreenWindow: jest.Mock;
function pressF11(
init: KeyboardEventInit = {},
target: EventTarget = document
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key: 'F11',
bubbles: true,
cancelable: true,
...init,
});
target.dispatchEvent(event);
return event;
}
function setHtmlFullscreenElement(element: Element | null): void {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
value: element,
});
}
beforeEach(() => {
toggleFullScreenWindow = jest.fn().mockResolvedValue({
isMaximized: false,
isFullScreen: true,
});
testWindow.electron = { toggleFullScreenWindow };
setHtmlFullscreenElement(null);
});
afterEach(() => {
delete testWindow.electron;
setHtmlFullscreenElement(null);
});
it('toggles window fullscreen through the bridge and swallows the key', () => {
const event = pressF11();
expect(toggleFullScreenWindow).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
expect(dialog.open).not.toHaveBeenCalled();
});
it('works while typing in an input — it is the exit from a fullscreen launch', () => {
const input = document.createElement('input');
document.body.appendChild(input);
pressF11({}, input);
expect(toggleFullScreenWindow).toHaveBeenCalledTimes(1);
input.remove();
});
it('leaves the key alone while the player owns HTML fullscreen', () => {
setHtmlFullscreenElement(document.createElement('div'));
const event = pressF11();
expect(toggleFullScreenWindow).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it('ignores modified and auto-repeated F11 presses', () => {
pressF11({ ctrlKey: true });
pressF11({ metaKey: true });
pressF11({ altKey: true });
pressF11({ shiftKey: true });
pressF11({ repeat: true });
expect(toggleFullScreenWindow).not.toHaveBeenCalled();
});
it('leaves F11 to the browser without a bridge', () => {
delete testWindow.electron;
const event = pressF11();
expect(event.defaultPrevented).toBe(false);
});
});
it('does not open duplicate dialogs while one is active', () => {
service.openShortcutsDialog();
service.openShortcutsDialog();
@@ -78,6 +78,10 @@ export class WorkspaceKeyboardShortcutsService {
}
private handleKeydown(event: KeyboardEvent): void {
if (this.handleWindowFullscreenToggle(event)) {
return;
}
if (
isTypingInInput(event) ||
!isKeyboardShortcutHelpTrigger(event) ||
@@ -90,6 +94,41 @@ export class WorkspaceKeyboardShortcutsService {
this.openShortcutsDialog();
}
/**
* F11 toggles OS-level window fullscreen in the desktop app. It is the
* way out of a fullscreen launch on Windows/Linux, where the title bar is
* hidden and the window controls hide themselves while fullscreen, so it
* is deliberately not gated by `isTypingInInput` — F11 is expected to
* work from any focus. While the player owns HTML-element fullscreen
* (`document.fullscreenElement`) its own F / Esc are in charge and F11
* must not yank OS fullscreen out from under it. Without a bridge (PWA)
* the browser keeps its own F11.
*/
private handleWindowFullscreenToggle(event: KeyboardEvent): boolean {
if (
event.key !== 'F11' ||
event.repeat ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
) {
return false;
}
const bridge = window.electron;
if (
typeof bridge?.toggleFullScreenWindow !== 'function' ||
document.fullscreenElement
) {
return false;
}
event.preventDefault();
void bridge.toggleFullScreenWindow().catch(() => undefined);
return true;
}
private getShortcutPlatform(): 'mac' | 'other' {
if (typeof navigator === 'undefined') {
return 'other';
@@ -1,3 +1,5 @@
@use '@angular/material' as mat;
:host {
display: contents;
}
@@ -21,19 +23,26 @@
}
.workspace-loading-overlay__card {
--sync-secondary: color-mix(
in srgb,
var(--app-on-surface) 78%,
var(--app-widget-bg)
);
--sync-accent: color-mix(
in srgb,
var(--app-selection-color) 60%,
var(--app-on-surface)
);
width: min(440px, 100%);
display: grid;
gap: 14px;
padding: 24px 24px 20px;
border-radius: 24px;
border: 1px solid var(--app-separator, rgba(255, 255, 255, 0.12));
background:
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0)
),
var(--mat-sys-surface-container-high);
border: 1px solid var(--app-separator);
// Keep a bounded glass tint without letting the scrim own text contrast.
background: color-mix(in srgb, var(--app-widget-bg) 96%, transparent);
color: var(--app-on-surface);
box-shadow:
0 24px 64px rgba(0, 0, 0, 0.32),
0 1px 0 rgba(255, 255, 255, 0.05) inset;
@@ -45,7 +54,7 @@
font-size: 1.55rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
color: var(--app-heading-color);
}
}
@@ -61,9 +70,9 @@
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(120, 220, 255, 0.24);
background: rgba(120, 220, 255, 0.08);
color: rgba(164, 233, 255, 1);
border: 1px solid var(--app-separator);
background: var(--app-hover-overlay);
color: var(--sync-secondary);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
@@ -71,9 +80,9 @@
}
.workspace-loading-overlay__badge--remote {
border-color: rgba(105, 195, 255, 0.34);
background: rgba(83, 158, 255, 0.12);
color: rgba(202, 230, 255, 1);
border-color: var(--app-selection-border);
background: var(--app-selection-surface);
color: var(--sync-accent);
}
.workspace-loading-overlay__phase,
@@ -84,14 +93,14 @@
.workspace-loading-overlay__phase {
font-size: 1rem;
font-weight: 560;
color: var(--app-body-color, var(--mat-sys-on-surface));
font-weight: 600;
color: var(--app-on-surface);
}
.workspace-loading-overlay__detail {
max-width: 32ch;
justify-self: center;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
color: var(--sync-secondary);
line-height: 1.45;
}
@@ -100,11 +109,22 @@
justify-self: center;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
color: var(--sync-secondary);
text-wrap: balance;
}
.workspace-loading-overlay__action {
@include mat.button-overrides(
(
outlined-label-text-color: var(--sync-accent),
outlined-label-text-weight: 600,
outlined-outline-color: var(--sync-secondary),
outlined-state-layer-color: var(--app-on-surface),
outlined-disabled-label-text-color: var(--sync-secondary),
outlined-disabled-outline-color: var(--app-separator),
)
);
justify-self: center;
min-width: 180px;
border-radius: 999px;