mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
commit
11414f0b94
626 files changed
+41521
-5239
No files matched your search
+2
-1
@@ -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
|
||||
"
|
||||
|
||||
+3
@@ -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());
|
||||
|
||||
+64
-2
@@ -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);
|
||||
|
||||
+7
-1
@@ -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',
|
||||
|
||||
+87
@@ -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();
|
||||
|
||||
+39
@@ -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';
|
||||
|
||||
+39
-19
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user