Merge origin/master into PR 955

This commit is contained in:
4gray committed 2026-05-22 01:34:56 +03:00
commit 2a769ad44e
89 files changed
+6476 -465

No files matched your search

@@ -2,7 +2,10 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
@@ -11,12 +14,31 @@ import {
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util';
import { DashboardDataService } from './dashboard-data.service';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DashboardDataService,
DashboardFavoriteItem,
GlobalRecentItem,
} from './dashboard-data.service';
describe('DashboardDataService', () => {
let service: DashboardDataService;
const playlistsLoadedSignal = signal(true);
const createPendingItems = <T = never>() => {
let resolvePending: (items: T[]) => void = () => {
throw new Error('Pending item promise resolved before init');
};
const promise = new Promise<T[]>((resolve) => {
resolvePending = resolve;
});
return {
promise,
resolve: resolvePending,
};
};
const createDefaultPlaylists = (): PlaylistMeta[] => [
{
_id: 'm3u-1',
@@ -73,6 +95,12 @@ describe('DashboardDataService', () => {
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
const xtreamDataSourceMock = {
getFavorites: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
removeFavorite: jest.fn().mockResolvedValue(undefined),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
const playlistMock: Playlist = {
_id: 'm3u-1',
title: 'M3U Playlist',
@@ -153,6 +181,14 @@ describe('DashboardDataService', () => {
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
dbServiceMock.removeFromFavorites.mockClear();
dbServiceMock.removeRecentItem.mockClear();
xtreamDataSourceMock.getFavorites.mockClear();
xtreamDataSourceMock.getFavorites.mockResolvedValue([]);
xtreamDataSourceMock.getRecentItems.mockClear();
xtreamDataSourceMock.getRecentItems.mockResolvedValue([]);
xtreamDataSourceMock.removeFavorite.mockClear();
xtreamDataSourceMock.removeFavorite.mockResolvedValue(undefined);
xtreamDataSourceMock.removeRecentItem.mockClear();
xtreamDataSourceMock.removeRecentItem.mockResolvedValue(undefined);
storeMock.dispatch.mockClear();
playbackPositionsMock.getAllPlaybackPositions.mockClear();
@@ -163,6 +199,10 @@ describe('DashboardDataService', () => {
DashboardDataService,
{ provide: Store, useValue: storeMock },
{ provide: DatabaseService, useValue: dbServiceMock },
{
provide: XTREAM_DATA_SOURCE,
useValue: xtreamDataSourceMock,
},
{
provide: PlaylistsService,
useValue: playlistsServiceMock,
@@ -348,6 +388,139 @@ describe('DashboardDataService', () => {
).toHaveLength(1);
});
it('includes PWA Xtream favorites from the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSourceMock.getFavorites.mockResolvedValue([
{
id: 51,
category_id: 12,
title: 'Action Movie',
rating: '8.0',
added_at: '2026-02-02T10:00:00.000Z',
poster_url: 'https://example.com/movie.png',
xtream_id: 5001,
type: 'movie',
},
]);
await service.reloadGlobalFavorites();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 51,
title: 'Action Movie',
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
}),
])
);
});
it('loads PWA Xtream favorites for multiple playlists in parallel', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://one.example.com',
},
{
_id: 'xtream-2',
title: 'Xtream Two',
serverUrl: 'https://two.example.com',
},
]);
const firstFavorites = createPendingItems();
xtreamDataSourceMock.getFavorites.mockImplementation((playlistId) =>
playlistId === 'xtream-1'
? firstFavorites.promise
: Promise.resolve([])
);
const reload = service.reloadGlobalFavorites();
await Promise.resolve();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-2'
);
firstFavorites.resolve([]);
await reload;
});
it('uses the epoch fallback for PWA Xtream favorites with empty added dates', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSourceMock.getFavorites.mockResolvedValue([
{
id: 52,
category_id: 12,
title: 'Undated Movie',
rating: '8.0',
added: '',
poster_url: 'https://example.com/movie.png',
xtream_id: 5002,
type: 'movie',
},
]);
await service.reloadGlobalFavorites();
expect(service.globalFavoriteItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 52,
added_at: new Date(0).toISOString(),
}),
])
);
});
it('does not load Stalker playlists through the PWA Xtream favorites path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
]);
await service.reloadGlobalFavorites();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledTimes(1);
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
});
it('builds the M3U favorites route', async () => {
await service.reloadGlobalFavorites();
const m3uItem = service
@@ -428,6 +601,35 @@ describe('DashboardDataService', () => {
);
});
it('removes PWA Xtream favorites through the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
const item = {
id: 51,
category_id: 12,
title: 'Action Movie',
poster_url: 'https://example.com/movie.png',
xtream_id: 5001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
} satisfies DashboardFavoriteItem;
await service.removeGlobalFavorite(item);
expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith(
51,
'xtream-1'
);
expect(dbServiceMock.removeFromFavorites).not.toHaveBeenCalled();
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
});
it('includes M3U recently viewed items in global recent items', () => {
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
@@ -910,6 +1112,110 @@ describe('DashboardDataService', () => {
).toEqual(['xtream-1', 'stalker-1', 'm3u-1']);
});
it('includes PWA Xtream recent items from the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
xtreamDataSourceMock.getRecentItems.mockResolvedValue([
{
id: 91,
category_id: 18,
title: 'Recent Movie',
rating: '7.8',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
},
]);
await service.reloadGlobalRecentItems();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 91,
title: 'Recent Movie',
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
viewed_at: '2026-04-21T10:00:00.000Z',
}),
])
);
});
it('loads PWA Xtream recent items for multiple playlists in parallel', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://one.example.com',
},
{
_id: 'xtream-2',
title: 'Xtream Two',
serverUrl: 'https://two.example.com',
},
]);
const firstRecentItems = createPendingItems();
xtreamDataSourceMock.getRecentItems.mockImplementation((playlistId) =>
playlistId === 'xtream-1'
? firstRecentItems.promise
: Promise.resolve([])
);
const reload = service.reloadGlobalRecentItems();
await Promise.resolve();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-2'
);
firstRecentItems.resolve([]);
await reload;
});
it('does not load Stalker playlists through the PWA Xtream recent path', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
playlistsSignal.set([
{
_id: 'xtream-1',
title: 'Xtream Playlist',
serverUrl: 'https://xtream.example.com',
},
{
_id: 'stalker-1',
title: 'Stalker Playlist',
serverUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
},
]);
await service.reloadGlobalRecentItems();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledTimes(1);
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
});
it('falls back to playlist metadata ordering when sources have no recent activity', async () => {
playlistsSignal.set([
{
@@ -1118,4 +1424,34 @@ describe('DashboardDataService', () => {
playlistsServiceMock.removeFromM3uRecentlyViewed
).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8');
});
it('removes PWA Xtream recently viewed through the active data source', async () => {
Object.defineProperty(window, 'electron', {
value: undefined,
configurable: true,
});
const item = {
id: 91,
category_id: 18,
title: 'Recent Movie',
viewed_at: '2026-04-21T10:00:00.000Z',
poster_url: 'https://example.com/recent-movie.png',
xtream_id: 7001,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
source: 'xtream',
} satisfies GlobalRecentItem;
await service.removeGlobalRecentItem(item);
expect(xtreamDataSourceMock.removeRecentItem).toHaveBeenCalledWith(
91,
'xtream-1'
);
expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled();
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
'xtream-1'
);
});
});
@@ -20,6 +20,10 @@ import {
GlobalRecentlyAddedKind,
PlaylistsService,
} from '@iptvnator/services';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
} from '@iptvnator/portal/xtream/data-access';
import {
buildPlaylistRecentItems,
Channel,
@@ -99,6 +103,7 @@ export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
export class DashboardDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
private readonly playlistsService = inject(PlaylistsService);
private readonly ngZone = inject(NgZone);
private readonly translate = inject(TranslateService);
@@ -475,7 +480,10 @@ export class DashboardDataService {
}
if (!window.electron) {
this.xtreamGlobalRecentItems.set([]);
const recentItems = await this.loadPwaXtreamGlobalRecentItems();
this.ngZone.run(() =>
this.xtreamGlobalRecentItems.set(recentItems)
);
this.globalRecentDbLoadedState.set(true);
this.finishInitialGlobalRecentLoadIfReady();
return;
@@ -577,7 +585,8 @@ export class DashboardDataService {
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
const favorites = await this.loadPwaXtreamGlobalFavorites();
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
this.finishInitialGlobalFavoritesLoadIfReady();
return;
}
@@ -599,6 +608,95 @@ export class DashboardDataService {
}
}
private async loadPwaXtreamGlobalRecentItems(): Promise<
GlobalRecentItem[]
> {
const nested = await Promise.all(
this.getXtreamPlaylists().map(async (playlist) => {
const rows = await this.xtreamDataSource.getRecentItems(
playlist._id
);
return rows.map((item) =>
this.mapPwaXtreamRecentItem(item, playlist)
);
})
);
return nested.reduce<GlobalRecentItem[]>(
(items, playlistItems) => items.concat(playlistItems),
[]
);
}
private async loadPwaXtreamGlobalFavorites(): Promise<
DashboardFavoriteItem[]
> {
const nested = await Promise.all(
this.getXtreamPlaylists().map(async (playlist) => {
const rows = await this.xtreamDataSource.getFavorites(
playlist._id
);
return rows.map((item) =>
this.mapPwaXtreamFavoriteItem(item, playlist)
);
})
);
return nested.reduce<DashboardFavoriteItem[]>(
(items, playlistItems) => items.concat(playlistItems),
[]
);
}
private getXtreamPlaylists(): PlaylistMeta[] {
return this.playlists().filter(
(playlist) => !!playlist.serverUrl && !playlist.macAddress
);
}
private mapPwaXtreamRecentItem(
item: XtreamContentItem,
playlist: PlaylistMeta
): GlobalRecentItem {
return {
id: item.id,
title: item.title,
type: this.normalizeXtreamActivityType(item.type),
playlist_id: playlist._id,
playlist_name: playlist.title || 'Xtream',
viewed_at: item.viewed_at ?? '',
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
private mapPwaXtreamFavoriteItem(
item: XtreamContentItem,
playlist: PlaylistMeta
): DashboardFavoriteItem {
return {
id: item.id,
title: item.title,
type: this.normalizeXtreamActivityType(item.type),
playlist_id: playlist._id,
playlist_name: playlist.title || 'Xtream',
added_at: item.added_at || item.added || new Date(0).toISOString(),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
source: 'xtream',
};
}
private normalizeXtreamActivityType(type: string): PortalActivityType {
if (type === 'live' || type === 'series') {
return type;
}
return 'movie';
}
private async refreshPlaylistBackedGlobalFavorites(): Promise<void> {
await this.reloadM3uGlobalFavorites();
this.finishInitialGlobalFavoritesLoadIfReady();
@@ -762,10 +860,17 @@ export class DashboardDataService {
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
if (window.electron) {
await this.dbService.removeRecentItem(
item.id as number,
item.playlist_id
);
} else {
await this.xtreamDataSource.removeRecentItem(
item.id as number,
item.playlist_id
);
}
await this.reloadGlobalRecentItems();
return;
}
@@ -916,10 +1021,17 @@ export class DashboardDataService {
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
if (window.electron) {
await this.dbService.removeFromFavorites(
item.id as number,
item.playlist_id
);
} else {
await this.xtreamDataSource.removeFavorite(
item.id as number,
item.playlist_id
);
}
await this.reloadGlobalFavorites();
return;
}
@@ -1052,8 +1164,7 @@ export class DashboardDataService {
const matchedFavoriteId =
channelIdFavoritePosition !== undefined &&
(channelUrlFavoritePosition === undefined ||
channelIdFavoritePosition <=
channelUrlFavoritePosition)
channelIdFavoritePosition <= channelUrlFavoritePosition)
? channelId
: channelUrlFavoritePosition !== undefined
? channelUrl
@@ -0,0 +1,105 @@
<section class="shortcuts-dialog">
<header class="shortcuts-dialog__header">
<div class="shortcuts-dialog__title-block">
<span class="shortcuts-dialog__hero-icon" aria-hidden="true">
<mat-icon>keyboard</mat-icon>
</span>
<div>
<h2 mat-dialog-title>
{{ 'WORKSPACE.SHORTCUTS.TITLE' | translate }}
</h2>
<p>{{ 'WORKSPACE.SHORTCUTS.SUBTITLE' | translate }}</p>
</div>
</div>
<div class="shortcuts-dialog__header-actions">
<span class="shortcuts-dialog__platform">
<mat-icon aria-hidden="true">{{ data.platformIcon }}</mat-icon>
{{ data.platformLabelKey | translate }}
</span>
<button
type="button"
mat-icon-button
class="shortcuts-dialog__close"
[attr.aria-label]="'CLOSE' | translate"
(click)="close()"
>
<mat-icon>close</mat-icon>
</button>
</div>
</header>
<mat-dialog-content class="shortcuts-dialog__content">
<div class="shortcuts-dialog__groups">
@for (group of groups; track group.id) {
<section class="shortcuts-dialog__group">
<header class="shortcuts-dialog__group-header">
<span
class="shortcuts-dialog__group-icon"
aria-hidden="true"
>
<mat-icon>{{ group.icon }}</mat-icon>
</span>
<h3>{{ group.labelKey | translate }}</h3>
</header>
<div class="shortcuts-dialog__rows">
@for (item of group.items; track item.id) {
<article class="shortcuts-dialog__row">
<span
class="shortcuts-dialog__action-icon"
aria-hidden="true"
>
<mat-icon>{{ item.icon }}</mat-icon>
</span>
<span class="shortcuts-dialog__label">
{{ item.labelKey | translate }}
</span>
<span class="shortcuts-dialog__chords">
@for (
chord of item.chords;
track chord.id
) {
<span
class="shortcuts-dialog__chord"
[attr.aria-label]="chord.ariaLabel"
>
@for (
key of chord.keys;
track key.id
) {
<kbd
[attr.aria-label]="
key.ariaLabel
"
[class.shortcuts-dialog__keycap--modifier]="
key.isModifier
"
>
{{ key.label }}
</kbd>
@if (!$last) {
<span
class="shortcuts-dialog__joiner"
aria-hidden="true"
>
+
</span>
}
}
</span>
}
</span>
</article>
}
</div>
</section>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end" class="shortcuts-dialog__actions">
<button type="button" mat-button (click)="close()">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
</section>
@@ -0,0 +1,342 @@
:host {
display: block;
app-region: no-drag;
}
.shortcuts-dialog {
--shortcut-surface: var(--app-widget-bg, #1e2330);
--shortcut-surface-raised: var(--app-widget-header-bg, #232840);
--shortcut-text: var(--app-heading-color, #d8dce8);
--shortcut-muted: var(--app-body-color, #8b93a8);
--shortcut-border: var(--app-separator, rgba(255, 255, 255, 0.14));
--shortcut-accent: var(--app-selection-color, #78adff);
--shortcut-panel-bg: color-mix(
in srgb,
var(--shortcut-surface) 86%,
var(--shortcut-surface-raised) 14%
);
--shortcut-panel-hover: color-mix(
in srgb,
var(--shortcut-accent) 10%,
var(--shortcut-surface-raised) 90%
);
--shortcut-key-bg: color-mix(
in srgb,
var(--shortcut-surface-raised) 88%,
white 12%
);
--shortcut-key-shadow: color-mix(in srgb, black 24%, transparent);
color: var(--shortcut-text);
background:
linear-gradient(
145deg,
color-mix(
in srgb,
var(--shortcut-accent) 8%,
var(--shortcut-surface) 92%
),
var(--shortcut-surface) 42%
),
var(--shortcut-surface);
border: 1px solid var(--shortcut-border);
border-radius: 16px;
box-shadow: 0 22px 70px color-mix(in srgb, black 34%, transparent);
overflow: hidden;
}
.shortcuts-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 20px 22px 16px;
border-bottom: 1px solid var(--shortcut-border);
}
.shortcuts-dialog__title-block {
display: flex;
align-items: center;
min-width: 0;
gap: 14px;
}
.shortcuts-dialog__hero-icon {
display: inline-grid;
flex: 0 0 auto;
place-items: center;
width: 46px;
height: 46px;
color: var(--shortcut-accent);
background: color-mix(in srgb, var(--shortcut-accent) 14%, transparent);
border: 1px solid
color-mix(in srgb, var(--shortcut-accent) 28%, transparent);
border-radius: 8px;
mat-icon {
width: 24px;
height: 24px;
font-size: 24px;
}
}
.shortcuts-dialog__header h2 {
margin: 0;
padding: 0;
font-size: 1.08rem;
font-weight: 700;
line-height: 1.25;
}
.shortcuts-dialog__header p {
margin: 5px 0 0;
color: var(--shortcut-muted);
font-size: 0.86rem;
line-height: 1.4;
}
.shortcuts-dialog__header-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.shortcuts-dialog__platform {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 0 10px;
color: var(--shortcut-muted);
background: color-mix(
in srgb,
var(--shortcut-surface-raised) 82%,
var(--shortcut-surface) 18%
);
border: 1px solid var(--shortcut-border);
border-radius: 8px;
font-size: 0.76rem;
font-weight: 650;
line-height: 1;
white-space: nowrap;
mat-icon {
width: 17px;
height: 17px;
font-size: 17px;
}
}
.shortcuts-dialog__close {
flex: 0 0 auto;
}
.shortcuts-dialog__content {
max-height: min(68vh, 670px);
padding: 12px;
}
.shortcuts-dialog__groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.shortcuts-dialog__group {
min-width: 0;
background: var(--shortcut-panel-bg);
border: 1px solid var(--shortcut-border);
border-radius: 8px;
overflow: hidden;
}
.shortcuts-dialog__group-header {
display: flex;
align-items: center;
gap: 9px;
min-height: 38px;
padding: 8px 12px;
border-bottom: 1px solid
color-mix(in srgb, var(--shortcut-border) 72%, transparent);
}
.shortcuts-dialog__group-icon,
.shortcuts-dialog__action-icon {
display: inline-grid;
flex: 0 0 auto;
place-items: center;
border-radius: 7px;
}
.shortcuts-dialog__group-icon {
width: 28px;
height: 28px;
color: var(--shortcut-accent);
background: color-mix(in srgb, var(--shortcut-accent) 12%, transparent);
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
}
.shortcuts-dialog__group h3 {
margin: 0;
color: var(--shortcut-text);
font-size: 0.74rem;
font-weight: 750;
line-height: 1.2;
text-transform: uppercase;
}
.shortcuts-dialog__rows {
display: grid;
gap: 1px;
}
.shortcuts-dialog__row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) max-content;
gap: 10px;
align-items: center;
min-height: 42px;
padding: 7px 10px;
font-size: 0.88rem;
line-height: 1.35;
transition:
background-color 140ms ease,
transform 140ms ease;
&:hover {
background: var(--shortcut-panel-hover);
}
}
.shortcuts-dialog__action-icon {
width: 28px;
height: 28px;
color: var(--shortcut-muted);
background: color-mix(
in srgb,
var(--shortcut-surface-raised) 78%,
var(--shortcut-surface) 22%
);
mat-icon {
width: 17px;
height: 17px;
font-size: 17px;
}
}
.shortcuts-dialog__label {
min-width: 0;
color: var(--shortcut-text);
font-size: 0.85rem;
}
.shortcuts-dialog__chords {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px 8px;
min-width: 0;
}
.shortcuts-dialog__chord {
display: inline-flex;
align-items: center;
gap: 4px;
}
.shortcuts-dialog__joiner {
color: var(--shortcut-muted);
font-size: 0.68rem;
font-weight: 700;
}
.shortcuts-dialog__chord kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
min-height: 27px;
padding: 2px 8px 3px;
color: var(--shortcut-text);
background: var(--shortcut-key-bg);
border: 1px solid var(--shortcut-border);
border-radius: 6px;
box-shadow:
inset 0 -2px 0 var(--shortcut-key-shadow),
0 1px 0 color-mix(in srgb, white 28%, transparent);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.72rem;
font-weight: 750;
line-height: 1;
white-space: nowrap;
}
.shortcuts-dialog__chord kbd.shortcuts-dialog__keycap--modifier {
min-width: 38px;
color: var(--shortcut-accent);
border-color: color-mix(
in srgb,
var(--shortcut-accent) 34%,
var(--shortcut-border)
);
}
.shortcuts-dialog__actions {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 9px 14px 14px;
border-top: 1px solid var(--shortcut-border);
}
@media (prefers-reduced-motion: no-preference) {
.shortcuts-dialog {
animation: shortcuts-dialog-enter 160ms ease-out;
}
}
@media (max-width: 640px) {
.shortcuts-dialog__header {
align-items: flex-start;
flex-direction: column;
padding: 16px 16px 12px;
}
.shortcuts-dialog__header-actions {
justify-content: space-between;
width: 100%;
}
.shortcuts-dialog__groups {
grid-template-columns: 1fr;
}
.shortcuts-dialog__row {
grid-template-columns: 28px minmax(0, 1fr);
gap: 8px 10px;
}
.shortcuts-dialog__chords {
grid-column: 2;
justify-content: flex-start;
}
}
@keyframes shortcuts-dialog-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -0,0 +1,138 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { WorkspaceKeyboardShortcutsDialogComponent } from './workspace-keyboard-shortcuts-dialog.component';
describe('WorkspaceKeyboardShortcutsDialogComponent', () => {
let fixture: ComponentFixture<WorkspaceKeyboardShortcutsDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WorkspaceKeyboardShortcutsDialogComponent],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
groups: [
{
id: 'global',
labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.GLOBAL',
icon: 'keyboard',
items: [
{
id: 'open-command-palette',
labelKey:
'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE',
icon: 'terminal',
chords: [
{
id: 'Cmd+K',
ariaLabel: 'Command + K',
keys: [
{
id: 'cmd',
label: 'Cmd',
ariaLabel: 'Command',
isModifier: true,
},
{
id: 'k',
label: 'K',
ariaLabel: 'K',
isModifier: false,
},
],
},
],
},
{
id: 'play-pause',
labelKey:
'WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE',
icon: 'play_arrow',
chords: [
{
id: 'Space',
ariaLabel: 'Space',
keys: [
{
id: 'space',
label: 'Space',
ariaLabel: 'Space',
isModifier: false,
},
],
},
{
id: 'K',
ariaLabel: 'K',
keys: [
{
id: 'k',
label: 'K',
ariaLabel: 'K',
isModifier: false,
},
],
},
],
},
],
},
],
platformIcon: 'laptop_mac',
platformLabelKey: 'WORKSPACE.SHORTCUTS.PLATFORM.MAC',
},
},
{
provide: MatDialogRef,
useValue: { close: jest.fn() },
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(
WorkspaceKeyboardShortcutsDialogComponent
);
fixture.detectChanges();
});
it('renders the dialog title and groups', () => {
const text = fixture.nativeElement.textContent;
expect(text).toContain('WORKSPACE.SHORTCUTS.TITLE');
expect(text).toContain('WORKSPACE.SHORTCUTS.GROUPS.GLOBAL');
});
it('renders shortcut labels and multiple keys', () => {
const text = fixture.nativeElement.textContent;
expect(text).toContain(
'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE'
);
expect(text).toContain('WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE');
expect(text).toContain('Cmd');
expect(text).toContain('Space');
expect(text).toContain('K');
});
it('renders the detected platform label', () => {
expect(fixture.nativeElement.textContent).toContain(
'WORKSPACE.SHORTCUTS.PLATFORM.MAC'
);
});
});
@@ -0,0 +1,36 @@
import { Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { KeyboardShortcutDisplayGroup } from '@iptvnator/portal/shared/util';
export interface WorkspaceKeyboardShortcutsDialogData {
groups: readonly KeyboardShortcutDisplayGroup[];
platformIcon: string;
platformLabelKey: string;
}
@Component({
selector: 'app-workspace-keyboard-shortcuts-dialog',
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
templateUrl: './workspace-keyboard-shortcuts-dialog.component.html',
styleUrl: './workspace-keyboard-shortcuts-dialog.component.scss',
})
export class WorkspaceKeyboardShortcutsDialogComponent {
private readonly dialogRef = inject(
MatDialogRef<WorkspaceKeyboardShortcutsDialogComponent>
);
readonly data =
inject<WorkspaceKeyboardShortcutsDialogData>(MAT_DIALOG_DATA);
readonly groups = this.data.groups;
close(): void {
this.dialogRef.close();
}
}
@@ -0,0 +1,96 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { WorkspaceKeyboardShortcutsService } from './workspace-keyboard-shortcuts.service';
describe('WorkspaceKeyboardShortcutsService', () => {
let afterClosed$: Subject<void>;
let dialog: { open: jest.Mock };
let service: WorkspaceKeyboardShortcutsService;
beforeEach(() => {
afterClosed$ = new Subject<void>();
dialog = {
open: jest.fn().mockReturnValue({
afterClosed: () => afterClosed$.asObservable(),
}),
};
TestBed.configureTestingModule({
providers: [
WorkspaceKeyboardShortcutsService,
{ provide: MatDialog, useValue: dialog },
],
});
service = TestBed.inject(WorkspaceKeyboardShortcutsService);
});
afterEach(() => {
Object.defineProperty(navigator, 'userAgentData', {
configurable: true,
value: undefined,
});
TestBed.resetTestingModule();
});
it('opens the shortcuts dialog when question mark is pressed', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: '?' }));
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('opens the shortcuts dialog for Shift+/', () => {
document.dispatchEvent(
new KeyboardEvent('keydown', { key: '/', shiftKey: true })
);
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('does not open while typing in an input', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.dispatchEvent(
new KeyboardEvent('keydown', {
key: '?',
bubbles: true,
})
);
expect(dialog.open).not.toHaveBeenCalled();
input.remove();
});
it('does not open duplicate dialogs while one is active', () => {
service.openShortcutsDialog();
service.openShortcutsDialog();
expect(dialog.open).toHaveBeenCalledTimes(1);
afterClosed$.next();
service.openShortcutsDialog();
expect(dialog.open).toHaveBeenCalledTimes(2);
});
it('uses userAgentData platform when resolving shortcut modifier labels', () => {
Object.defineProperty(navigator, 'userAgentData', {
configurable: true,
value: { platform: 'Windows' },
});
service.openShortcutsDialog();
const dialogData = dialog.open.mock.calls[0][1].data;
const commandPaletteShortcut = dialogData.groups
.flatMap((group) => group.items)
.find((item) => item.id === 'open-command-palette');
expect(dialogData.platformLabelKey).toBe(
'WORKSPACE.SHORTCUTS.PLATFORM.OTHER'
);
expect(commandPaletteShortcut?.chords[0].keys[0].label).toBe('Ctrl');
});
});
@@ -0,0 +1,99 @@
import { DestroyRef, Injectable, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import {
getKeyboardShortcutGroups,
isKeyboardShortcutHelpTrigger,
isTypingInInput,
} from '@iptvnator/portal/shared/util';
import {
WorkspaceKeyboardShortcutsDialogComponent,
WorkspaceKeyboardShortcutsDialogData,
} from './workspace-keyboard-shortcuts-dialog.component';
@Injectable()
export class WorkspaceKeyboardShortcutsService {
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly onDocumentKeydown = (event: KeyboardEvent): void =>
this.handleKeydown(event);
private dialogRef: MatDialogRef<
WorkspaceKeyboardShortcutsDialogComponent,
unknown
> | null = null;
constructor() {
if (typeof document !== 'undefined') {
document.addEventListener('keydown', this.onDocumentKeydown);
this.destroyRef.onDestroy(() => {
document.removeEventListener('keydown', this.onDocumentKeydown);
});
}
}
openShortcutsDialog(): void {
if (this.dialogRef) {
return;
}
const platform = this.getShortcutPlatform();
const dialogRef = this.dialog.open<
WorkspaceKeyboardShortcutsDialogComponent,
WorkspaceKeyboardShortcutsDialogData
>(WorkspaceKeyboardShortcutsDialogComponent, {
width: 'min(960px, 94vw)',
maxWidth: '94vw',
panelClass: 'workspace-shortcuts-overlay',
autoFocus: false,
data: {
groups: getKeyboardShortcutGroups({
isMac: platform === 'mac',
isElectron: this.isElectron(),
}),
platformIcon:
platform === 'mac' ? 'laptop_mac' : 'desktop_windows',
platformLabelKey:
platform === 'mac'
? 'WORKSPACE.SHORTCUTS.PLATFORM.MAC'
: 'WORKSPACE.SHORTCUTS.PLATFORM.OTHER',
},
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.dialogRef = null;
});
}
private handleKeydown(event: KeyboardEvent): void {
if (isTypingInInput(event) || !isKeyboardShortcutHelpTrigger(event)) {
return;
}
event.preventDefault();
this.openShortcutsDialog();
}
private getShortcutPlatform(): 'mac' | 'other' {
if (typeof navigator === 'undefined') {
return 'other';
}
const navigatorWithUserAgentData = navigator as Navigator & {
userAgentData?: { platform?: string };
};
const platform =
navigatorWithUserAgentData.userAgentData?.platform ||
navigator.userAgent;
return /Mac|iPhone|iPad|iPod/i.test(platform) ? 'mac' : 'other';
}
private isElectron(): boolean {
return typeof window !== 'undefined' && !!window.electron;
}
}
@@ -65,6 +65,15 @@
importing their first source). Only the per-playlist shortcut
and bulk actions are hidden on Settings. -->
<div class="header-actions header-actions--leading">
<button
type="button"
mat-icon-button
(click)="onShortcutsRequested()"
[matTooltip]="'WORKSPACE.SHORTCUTS.OPEN_TOOLTIP' | translate"
[attr.aria-label]="'WORKSPACE.SHORTCUTS.OPEN_ARIA' | translate"
>
<mat-icon>keyboard</mat-icon>
</button>
<button
type="button"
mat-icon-button
@@ -105,6 +105,18 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(requested).toHaveBeenCalledTimes(1);
});
it('emits keyboard shortcuts requests from the help button', () => {
const requested = jest.fn();
component.shortcutsRequested.subscribe(requested);
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'button[aria-label="WORKSPACE.SHORTCUTS.OPEN_ARIA"]'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the removed global favorites shortcut', () => {
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector(
@@ -61,6 +61,7 @@ export class WorkspaceShellHeaderComponent {
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly shortcutsRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
@@ -95,6 +96,10 @@ export class WorkspaceShellHeaderComponent {
this.commandPaletteRequested.emit();
}
onShortcutsRequested(): void {
this.shortcutsRequested.emit();
}
onHeaderShortcutRequested(): void {
this.headerShortcutRequested.emit();
}
@@ -44,6 +44,7 @@
(searchChanged)="facade.onSearchInput($event)"
(searchSubmitted)="facade.onSearchEnter($event)"
(commandPaletteRequested)="facade.openCommandPalette()"
(shortcutsRequested)="keyboardShortcuts.openShortcutsDialog()"
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
@@ -7,6 +7,7 @@ import {
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { RouterOutlet, provideRouter } from '@angular/router';
import { By } from '@angular/platform-browser';
import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
@@ -16,6 +17,7 @@ import {
WorkspaceHeaderBulkAction,
WorkspaceShellFacade,
} from './services/workspace-shell.facade';
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
@Component({
selector: 'app-workspace-shell-rail',
@@ -62,6 +64,7 @@ class MockWorkspaceShellHeaderComponent {
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
readonly shortcutsRequested = output<void>();
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
@@ -118,6 +121,10 @@ class MockPlaylistDropZoneDirective {
})
class MockWorkspaceShellImportOverlayComponent {}
class MockWorkspaceKeyboardShortcutsService {
openShortcutsDialog = jest.fn();
}
class MockWorkspaceShellFacade {
readonly brandLink = signal('/workspace/dashboard');
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
@@ -216,6 +223,10 @@ describe('WorkspaceShellComponent', () => {
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
@@ -265,6 +276,10 @@ describe('WorkspaceShellComponent', () => {
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
@@ -288,4 +303,51 @@ describe('WorkspaceShellComponent', () => {
)
).not.toBeNull();
});
it('opens keyboard shortcuts when the header requests them', async () => {
const facade = new MockWorkspaceShellFacade();
await TestBed.configureTestingModule({
imports: [WorkspaceShellComponent],
providers: [provideRouter([])],
})
.overrideComponent(WorkspaceShellComponent, {
set: {
imports: [
RouterOutlet,
MockExternalPlaybackDockComponent,
MockPlaylistDropOverlayComponent,
MockPlaylistDropZoneDirective,
MockWorkspaceShellContextSidebarComponent,
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
],
providers: [
{
provide: WorkspaceShellFacade,
useValue: facade,
},
{
provide: WorkspaceKeyboardShortcutsService,
useClass: MockWorkspaceKeyboardShortcutsService,
},
],
},
})
.compileComponents();
const fixture = TestBed.createComponent(WorkspaceShellComponent);
fixture.detectChanges();
const shortcutsService = fixture.debugElement.injector.get(
WorkspaceKeyboardShortcutsService
) as unknown as MockWorkspaceKeyboardShortcutsService;
const header = fixture.debugElement.query(
By.directive(MockWorkspaceShellHeaderComponent)
).componentInstance as MockWorkspaceShellHeaderComponent;
header.shortcutsRequested.emit();
expect(shortcutsService.openShortcutsDialog).toHaveBeenCalledTimes(1);
});
});
@@ -12,6 +12,7 @@ import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/w
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './services/workspace-shell-command-palette.service';
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
@Component({
selector: 'app-workspace-shell',
@@ -31,8 +32,10 @@ import { WorkspaceShellCommandPaletteService } from './services/workspace-shell-
WorkspaceShellFacade,
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
WorkspaceKeyboardShortcutsService,
],
})
export class WorkspaceShellComponent {
readonly facade = inject(WorkspaceShellFacade);
readonly keyboardShortcuts = inject(WorkspaceKeyboardShortcutsService);
}