feat: implement recently added widget with data mapping and default settings

This commit is contained in:
4gray committed 2026-03-28 15:35:55 +01:00
1 parent 43426114d6
commit 36c3d26559
9 files changed
+190 -594

No files matched your search

@@ -56,6 +56,7 @@ describe('DashboardDataService', () => {
};
const dbServiceMock = {
getGlobalRecentlyAdded: jest.fn().mockResolvedValue([]),
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
getGlobalFavorites: jest.fn().mockResolvedValue([]),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
@@ -108,6 +109,10 @@ describe('DashboardDataService', () => {
};
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
playlistsServiceMock.getPlaylistById.mockClear();
playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock));
playlistsServiceMock.setFavorites.mockClear();
@@ -121,6 +126,8 @@ describe('DashboardDataService', () => {
);
dbServiceMock.getGlobalFavorites.mockClear();
dbServiceMock.getGlobalFavorites.mockResolvedValue([]);
dbServiceMock.getGlobalRecentlyAdded.mockClear();
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([]);
dbServiceMock.getGlobalRecentlyViewed.mockClear();
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
dbServiceMock.removeFromFavorites.mockClear();
@@ -154,42 +161,6 @@ describe('DashboardDataService', () => {
jest.clearAllMocks();
});
it('treats an empty provider scope as all providers', () => {
const result = service.matchesScope('xtream-1', 'xtream', {
providers: [],
playlistIds: [],
});
expect(result).toBe(true);
});
it('filters out items when provider is excluded', () => {
const result = service.matchesScope('xtream-1', 'xtream', {
providers: ['stalker'],
playlistIds: [],
});
expect(result).toBe(false);
});
it('filters out items when playlist is not selected', () => {
const result = service.matchesScope('xtream-1', 'xtream', {
providers: ['xtream'],
playlistIds: ['m3u-1'],
});
expect(result).toBe(false);
});
it('uses playlist metadata as provider fallback when source is missing', () => {
const result = service.matchesScope('xtream-1', undefined, {
providers: ['xtream'],
playlistIds: [],
});
expect(result).toBe(true);
});
it('includes M3U favorites in global favorite items', async () => {
await service.reloadGlobalFavorites();
@@ -246,15 +217,6 @@ describe('DashboardDataService', () => {
);
});
it('matches M3U provider scope', () => {
const result = service.matchesScope('m3u-1', 'm3u', {
providers: ['m3u'],
playlistIds: [],
});
expect(result).toBe(true);
});
it('includes M3U recently viewed items in global recent items', () => {
expect(service.globalRecentItems()).toEqual(
expect.arrayContaining([
@@ -270,6 +232,66 @@ describe('DashboardDataService', () => {
);
});
it('maps recently added Xtream rows for the widget', async () => {
dbServiceMock.getGlobalRecentlyAdded.mockResolvedValue([
{
id: 11,
category_id: 77,
title: 'Fresh Movie',
rating: '8.1',
added: '1774298340',
added_at: '1774298340',
poster_url: 'https://example.com/poster.png',
xtream_id: 501,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
},
]);
await expect(
service.getGlobalRecentlyAddedItems('all', 25)
).resolves.toEqual([
expect.objectContaining({
id: 11,
title: 'Fresh Movie',
type: 'movie',
playlist_id: 'xtream-1',
source: 'xtream',
added_at: expect.any(String),
}),
]);
expect(dbServiceMock.getGlobalRecentlyAdded).toHaveBeenCalledWith(
'all',
25
);
});
it('builds the Xtream recently added route', () => {
const item = {
id: 11,
title: 'Fresh Series',
type: 'series',
playlist_id: 'xtream-1',
playlist_name: 'Xtream Playlist',
added_at: '2026-03-27T14:39:00.000Z',
category_id: 77,
xtream_id: 501,
poster_url: 'https://example.com/poster.png',
source: 'xtream',
} as const;
expect(service.getRecentlyAddedLink(item)).toEqual([
'/workspace',
'xtreams',
'xtream-1',
'series',
'77',
'501',
]);
expect(service.getRecentlyAddedNavigationState(item)).toBeUndefined();
});
it('builds the M3U recent route with navigation state', () => {
const m3uItem = service
.globalRecentItems()
@@ -8,11 +8,17 @@ import {
selectAllPlaylistsMeta,
} from 'm3u-state';
import { firstValueFrom, startWith } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
DatabaseService,
GlobalRecentlyAddedKind,
PlaylistsService,
} from 'services';
import {
buildPlaylistRecentItems,
Channel,
Playlist,
PortalAddedItem,
PortalActivityItem,
PlaylistMeta,
PortalActivityType,
PortalFavoriteItem,
@@ -23,6 +29,7 @@ import {
buildStalkerFavoriteItems,
getActivityTypeLabelKey,
mapDbFavoriteToItem,
mapDbRecentlyAddedToItem,
mapDbRecentToItem,
toDateTimestamp,
toTimestamp,
@@ -32,11 +39,6 @@ import {
getRecentItemNavigation,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import {
DashboardWidgetProvider,
DashboardWidgetScopeSettings,
createDefaultWidgetScope,
} from './dashboard-widget.model';
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
@@ -44,6 +46,9 @@ export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
export type GlobalRecentItem = PortalRecentItem;
/** @deprecated Use {@link PortalFavoriteItem} from `shared-interfaces` instead. */
export type DashboardFavoriteItem = PortalFavoriteItem;
/** @deprecated Use {@link PortalAddedItem} from `shared-interfaces` instead. */
export type DashboardRecentlyAddedItem = PortalAddedItem;
export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
@Injectable({ providedIn: 'root' })
export class DashboardDataService {
@@ -67,7 +72,7 @@ export class DashboardDataService {
readonly playlistBackedGlobalRecentItems = computed<GlobalRecentItem[]>(
() => {
this.languageTick();
this.languageTick();
return buildPlaylistRecentItems(this.playlists(), {
stalker: this.translateText(
'WORKSPACE.DASHBOARD.STALKER_PORTAL'
@@ -164,6 +169,20 @@ export class DashboardDataService {
await Promise.all([xtreamReload, m3uReload]);
}
async getGlobalRecentlyAddedItems(
kind: DashboardRecentlyAddedFilterKind,
limit = 200
): Promise<DashboardRecentlyAddedItem[]> {
if (!window.electron) {
return [];
}
const items = await this.dbService.getGlobalRecentlyAdded(kind, limit);
return items
.map((item) => mapDbRecentlyAddedToItem(item))
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at));
}
private async reloadXtreamGlobalFavorites(): Promise<void> {
if (!window.electron) {
this.xtreamGlobalFavorites.set([]);
@@ -233,49 +252,6 @@ export class DashboardDataService {
return type === 'series';
}
matchesScope(
playlistId: string,
source: 'xtream' | 'stalker' | 'm3u' | undefined,
scope?: DashboardWidgetScopeSettings
): boolean {
const normalizedScope = this.normalizeScope(scope);
const provider = this.getProviderFromSource(
source,
this.getPlaylistProviderType(playlistId)
);
const providerAllowed =
normalizedScope.providers.length === 0 ||
normalizedScope.providers.includes(provider);
if (!providerAllowed) {
return false;
}
if (normalizedScope.playlistIds.length === 0) {
return true;
}
return normalizedScope.playlistIds.includes(playlistId);
}
getPlaylistProviderType(playlistId: string): DashboardWidgetProvider {
const playlist = this.playlists().find(
(item) => item._id === playlistId
);
if (!playlist) {
return 'm3u';
}
if (playlist.serverUrl) {
return 'xtream';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'm3u';
}
getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
@@ -381,6 +357,18 @@ export class DashboardDataService {
}
getFavoriteItemProviderLabel(item: DashboardFavoriteItem): string {
return this.getActivityItemProviderLabel(item);
}
getRecentlyAddedItemProviderLabel(
item: DashboardRecentlyAddedItem
): string {
return this.getActivityItemProviderLabel(item);
}
private getActivityItemProviderLabel(
item: Pick<PortalActivityItem, 'source'>
): string {
this.languageTick();
if (item.source === 'stalker') {
@@ -396,6 +384,18 @@ export class DashboardDataService {
}
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
return this.getActivityItemTypeLabel(item);
}
getRecentlyAddedItemTypeLabel(
item: DashboardRecentlyAddedItem
): string {
return this.getActivityItemTypeLabel(item);
}
private getActivityItemTypeLabel(
item: Pick<PortalActivityItem, 'type'>
): string {
this.languageTick();
return this.translateText(getActivityTypeLabelKey(item.type));
}
@@ -410,6 +410,16 @@ export class DashboardDataService {
return getGlobalFavoriteNavigation(item).state;
}
getRecentlyAddedLink(item: DashboardRecentlyAddedItem): string[] {
return getGlobalFavoriteNavigation(item).link;
}
getRecentlyAddedNavigationState(
item: DashboardRecentlyAddedItem
): WorkspaceNavigationTarget['state'] {
return getGlobalFavoriteNavigation(item).state;
}
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
if (item.source === 'xtream') {
await this.dbService.removeFromFavorites(
@@ -486,27 +496,6 @@ export class DashboardDataService {
return new Date(timestamp).toLocaleString(this.getLocale());
}
private normalizeScope(
scope?: DashboardWidgetScopeSettings
): DashboardWidgetScopeSettings {
const fallback = createDefaultWidgetScope();
return {
providers: [...(scope?.providers ?? fallback.providers)],
playlistIds: [...(scope?.playlistIds ?? fallback.playlistIds)],
};
}
private getProviderFromSource(
source: 'xtream' | 'stalker' | 'm3u' | undefined,
fallback: DashboardWidgetProvider
): DashboardWidgetProvider {
if (source === 'xtream' || source === 'stalker' || source === 'm3u') {
return source;
}
return fallback;
}
private async loadM3uPlaylistFavorites(
playlistMeta: PlaylistMeta
): Promise<DashboardFavoriteItem[]> {
@@ -1,5 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { DashboardWidgetScopeSettings } from './dashboard-widget.model';
import { DashboardLayoutService } from './dashboard-layout.service';
const STORAGE_KEY = 'workspace-dashboard-layout-v3';
@@ -19,31 +18,6 @@ describe('DashboardLayoutService', () => {
localStorage.removeItem(STORAGE_KEY);
});
it('normalizes scope providers and playlist ids when setting scope', () => {
service.setWidgetScope('recently-watched', {
providers: ['xtream', 'xtream', 'invalid'],
playlistIds: ['playlist-a', '', 'playlist-a'],
} as unknown as DashboardWidgetScopeSettings);
const widget = service.getWidget('recently-watched');
expect(widget?.settings?.scope).toEqual({
providers: ['xtream'],
playlistIds: ['playlist-a'],
});
});
it('allows an empty provider list to represent all providers', () => {
service.setWidgetScope('recently-watched', {
providers: ['m3u'],
playlistIds: [],
});
service.toggleWidgetScopeProvider('recently-watched', 'm3u');
const providers =
service.getWidget('recently-watched')?.settings?.scope?.providers ?? [];
expect(providers).toEqual([]);
});
it('persists layout changes to localStorage', () => {
service.toggleWidget('source-stats');
TestBed.flushEffects();
@@ -55,6 +29,19 @@ describe('DashboardLayoutService', () => {
widgets?: Array<{ id: string; enabled: boolean }>;
};
const sourceStats = parsed.widgets?.find((widget) => widget.id === 'source-stats');
expect(sourceStats?.enabled).toBe(false);
expect(sourceStats?.enabled).toBe(true);
});
it('adds the recently added widget disabled by default during normalization', () => {
const widget = service.getWidget('recently-added');
expect(widget).toEqual(
expect.objectContaining({
type: 'recently-added',
enabled: false,
size: 'half',
})
);
expect((widget as { settings?: unknown } | undefined)?.settings).toBeUndefined();
});
});
@@ -1,15 +1,11 @@
import { effect, Injectable, signal } from '@angular/core';
import {
ALL_DASHBOARD_WIDGET_PROVIDERS,
DASHBOARD_LAYOUT_VERSION,
DashboardLayoutState,
DashboardWidgetConfig,
DashboardWidgetProvider,
DashboardWidgetScopeSettings,
DashboardWidgetSize,
DASHBOARD_WIDGET_SIZE_OPTIONS,
DEFAULT_DASHBOARD_WIDGETS,
createDefaultWidgetScope,
} from './dashboard-widget.model';
const DASHBOARD_LAYOUT_STORAGE_KEY = 'workspace-dashboard-layout-v3';
@@ -35,47 +31,6 @@ export class DashboardLayoutService {
return this.state().widgets.find((widget) => widget.id === widgetId);
}
setWidgetScope(widgetId: string, scope: DashboardWidgetScopeSettings): void {
this.updateWidget(widgetId, (widget) => ({
...widget,
settings: {
...widget.settings,
scope: this.normalizeScope(scope),
},
}));
}
toggleWidgetScopeProvider(
widgetId: string,
provider: DashboardWidgetProvider
): void {
const widget = this.getWidget(widgetId);
const currentScope = this.normalizeScope(widget?.settings?.scope);
const hasProvider = currentScope.providers.includes(provider);
const providers = hasProvider
? currentScope.providers.filter((item) => item !== provider)
: [...currentScope.providers, provider];
this.setWidgetScope(widgetId, {
...currentScope,
providers,
});
}
toggleWidgetScopePlaylist(widgetId: string, playlistId: string): void {
const widget = this.getWidget(widgetId);
const currentScope = this.normalizeScope(widget?.settings?.scope);
const hasPlaylist = currentScope.playlistIds.includes(playlistId);
const playlistIds = hasPlaylist
? currentScope.playlistIds.filter((id) => id !== playlistId)
: [...currentScope.playlistIds, playlistId];
this.setWidgetScope(widgetId, {
...currentScope,
playlistIds,
});
}
setWidgetSize(widgetId: string, size: DashboardWidgetSize): void {
this.updateWidget(widgetId, (widget) => ({
...widget,
@@ -280,15 +235,13 @@ export class DashboardLayoutService {
private cloneWidget(widget: DashboardWidgetConfig): DashboardWidgetConfig {
return {
...widget,
settings: widget.settings
? {
...widget.settings,
scope: widget.settings.scope
? this.normalizeScope(widget.settings.scope)
: undefined,
}
: undefined,
id: widget.id,
type: widget.type,
title: widget.title,
description: widget.description,
size: this.normalizeSize(widget.size),
enabled: widget.enabled,
order: widget.order,
};
}
@@ -296,58 +249,20 @@ export class DashboardLayoutService {
defaultWidget: DashboardWidgetConfig,
loaded: DashboardWidgetConfig
): DashboardWidgetConfig {
const merged = {
...defaultWidget,
...loaded,
size: this.normalizeSize(loaded.size ?? defaultWidget.size),
settings: this.mergeSettings(defaultWidget, loaded),
};
return {
...merged,
id: defaultWidget.id,
type: defaultWidget.type,
title: defaultWidget.title,
description: defaultWidget.description,
};
}
private mergeSettings(
defaultWidget: DashboardWidgetConfig,
loaded: DashboardWidgetConfig
) {
if (!defaultWidget.settings && !loaded.settings) {
return undefined;
}
return {
...defaultWidget.settings,
...loaded.settings,
scope:
loaded.settings?.scope || defaultWidget.settings?.scope
? this.normalizeScope(
loaded.settings?.scope ?? defaultWidget.settings?.scope
)
: undefined,
};
}
private normalizeScope(
scope?: DashboardWidgetScopeSettings
): DashboardWidgetScopeSettings {
const defaultScope = createDefaultWidgetScope();
const rawProviders = Array.isArray(scope?.providers)
? scope.providers
: defaultScope.providers;
const providers = [...new Set(rawProviders)].filter(
(provider): provider is DashboardWidgetProvider =>
ALL_DASHBOARD_WIDGET_PROVIDERS.includes(
provider as DashboardWidgetProvider
)
);
const playlistIds = [...new Set(scope?.playlistIds ?? [])].filter(Boolean);
return {
providers,
playlistIds,
size: this.normalizeSize(loaded.size ?? defaultWidget.size),
enabled:
typeof loaded.enabled === 'boolean'
? loaded.enabled
: defaultWidget.enabled,
order:
typeof loaded.order === 'number'
? loaded.order
: defaultWidget.order,
};
}
@@ -1,8 +1,10 @@
import {
GlobalFavoriteItem as DbGlobalFavoriteItem,
GlobalRecentlyAddedItem as DbGlobalRecentlyAddedItem,
GlobalRecentItem as DbGlobalRecentItem,
} from 'services';
import {
PortalAddedItem,
PlaylistMeta,
PortalActivityType,
PortalFavoriteItem,
@@ -60,6 +62,23 @@ export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
};
}
export function mapDbRecentlyAddedToItem(
item: DbGlobalRecentlyAddedItem
): PortalAddedItem {
return {
id: item.id,
title: item.title,
type: normalizeActivityType(item.type),
playlist_id: item.playlist_id,
playlist_name: item.playlist_name,
added_at: normalizeStalkerDate(item.added_at),
category_id: item.category_id,
xtream_id: item.xtream_id,
poster_url: item.poster_url,
source: 'xtream',
};
}
// ────── Stalker playlist → ViewModel ──────
export function buildStalkerRecentItems(
@@ -2,19 +2,10 @@ export type DashboardWidgetType =
| 'source-stats'
| 'continue-watching'
| 'recently-watched'
| 'recently-added'
| 'global-favorites';
export type DashboardWidgetSize = 'one-third' | 'half' | 'two-thirds' | 'full';
export type DashboardWidgetProvider = 'm3u' | 'xtream' | 'stalker';
export interface DashboardWidgetScopeSettings {
providers: DashboardWidgetProvider[];
playlistIds: string[];
}
export interface DashboardWidgetSettings {
scope?: DashboardWidgetScopeSettings;
}
export interface DashboardWidgetConfig {
id: string;
@@ -24,7 +15,6 @@ export interface DashboardWidgetConfig {
size: DashboardWidgetSize;
enabled: boolean;
order: number;
settings?: DashboardWidgetSettings;
}
export interface DashboardLayoutState {
@@ -32,29 +22,13 @@ export interface DashboardLayoutState {
widgets: DashboardWidgetConfig[];
}
export const DASHBOARD_LAYOUT_VERSION = 12;
export const DASHBOARD_LAYOUT_VERSION = 14;
export const DASHBOARD_WIDGET_SIZE_OPTIONS: DashboardWidgetSize[] = [
'one-third',
'half',
'two-thirds',
'full',
];
export const ALL_DASHBOARD_WIDGET_PROVIDERS: DashboardWidgetProvider[] = [
'm3u',
'xtream',
'stalker',
];
export const DEFAULT_WIDGET_SCOPE: DashboardWidgetScopeSettings = {
providers: [...ALL_DASHBOARD_WIDGET_PROVIDERS],
playlistIds: [],
};
export function createDefaultWidgetScope(): DashboardWidgetScopeSettings {
return {
providers: [...ALL_DASHBOARD_WIDGET_PROVIDERS],
playlistIds: [],
};
}
export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
{
@@ -83,9 +57,15 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
size: 'two-thirds',
enabled: true,
order: 4,
settings: {
scope: createDefaultWidgetScope(),
},
},
{
id: 'recently-added',
type: 'recently-added',
title: 'WORKSPACE.DASHBOARD.RECENTLY_ADDED',
description: 'WORKSPACE.DASHBOARD.RECENTLY_ADDED_DESC',
size: 'half',
enabled: false,
order: 5,
},
{
id: 'global-favorites',
@@ -94,9 +74,6 @@ export const DEFAULT_DASHBOARD_WIDGETS: DashboardWidgetConfig[] = [
description: 'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES_DESC',
size: 'one-third',
enabled: true,
order: 5,
settings: {
scope: createDefaultWidgetScope(),
},
order: 6,
},
];
@@ -188,122 +188,6 @@
"
></mat-slide-toggle>
</div>
@if (supportsScope(widget)) {
<mat-expansion-panel class="advanced-panel">
<mat-expansion-panel-header>
<mat-panel-title>{{
'WORKSPACE.DASHBOARD.WIDGET_ADVANCED'
| translate
}}</mat-panel-title>
<mat-panel-description>{{
getScopePanelDescription(widget)
}}</mat-panel-description>
</mat-expansion-panel-header>
<section class="scope-editor">
<p class="scope-editor__title">
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE'
| translate
}}
</p>
<p class="scope-editor__subtitle">
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PROVIDERS'
| translate
}}
{{
getScopeProviderSummary(
widget
)
}}
</p>
@if (isAllProvidersScope(widget)) {
<p
class="scope-editor__subtitle"
>
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_ALL_HINT'
| translate
}}
</p>
}
<mat-chip-listbox
class="scope-providers"
multiple
>
@for (
provider of providerOptions;
track provider
) {
<mat-chip-option
[selected]="
isProviderSelected(
widget,
provider
)
"
(click)="
toggleScopeProvider(
widget.id,
provider
)
"
>
{{
getProviderLabel(
provider
)
}}
</mat-chip-option>
}
</mat-chip-listbox>
<p class="scope-editor__subtitle">
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_PLAYLISTS'
| translate
}}
</p>
<div class="scope-playlists">
@for (
playlist of getScopePlaylists(
widget
);
track playlist._id
) {
<mat-checkbox
[checked]="
isPlaylistSelected(
widget,
playlist._id
)
"
(change)="
toggleScopePlaylist(
widget.id,
playlist._id
)
"
>
{{
getPlaylistTitle(
playlist
)
}}
</mat-checkbox>
} @empty {
<p class="scope-empty">
{{
'WORKSPACE.DASHBOARD.WIDGET_SCOPE_EMPTY'
| translate
}}
</p>
}
</div>
</section>
</mat-expansion-panel>
}
</article>
}
</div>
@@ -197,72 +197,6 @@
font-weight: 600;
}
.advanced-panel {
width: 100%;
border: 1px solid var(--mat-sys-outline-variant);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 82%,
var(--dashboard-frame-tint) 18%
);
}
.advanced-panel :is(.mat-expansion-panel-header, .mat-expansion-panel-content) {
background: transparent;
}
.scope-editor {
width: 100%;
border-top: 1px solid var(--mat-sys-outline-variant);
padding-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.scope-editor__title {
margin: 0;
font-size: 0.8rem;
font-weight: 600;
}
.scope-editor__subtitle {
margin: 0;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
}
.scope-providers {
width: 100%;
}
.scope-playlists {
max-height: 200px;
overflow: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 2px;
}
.scope-empty {
margin: 0;
font-size: 0.76rem;
color: var(--mat-sys-on-surface-variant);
}
:host ::ng-deep .scope-playlists .mdc-form-field {
max-width: 100%;
}
:host ::ng-deep .scope-playlists .mdc-form-field > label {
display: inline-block;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.widget-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
@@ -6,22 +6,15 @@ import {
import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatIcon } from '@angular/material/icon';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { startWith } from 'rxjs';
import {
DashboardLayoutService,
DashboardWidgetConfig,
DashboardWidgetProvider,
DashboardWidgetSize,
createDefaultWidgetScope,
} from 'workspace-dashboard-data-access';
import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
@@ -31,9 +24,6 @@ import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
DashboardWidgetHostComponent,
DragDropModule,
MatButtonModule,
MatCheckboxModule,
MatChipsModule,
MatExpansionModule,
MatIcon,
MatSlideToggleModule,
MatTooltip,
@@ -43,20 +33,13 @@ import { DashboardWidgetHostComponent } from 'workspace-dashboard-ui';
styleUrl: './workspace-dashboard.component.scss',
})
export class WorkspaceDashboardComponent {
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
readonly editMode = signal(false);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
{ initialValue: null }
);
readonly providerOptions: DashboardWidgetProvider[] = [
'm3u',
'xtream',
'stalker',
];
private readonly sizeOrder: DashboardWidgetSize[] = [
'one-third',
'half',
@@ -136,101 +119,6 @@ export class WorkspaceDashboardComponent {
this.layoutService.toggleWidget(widgetId);
}
supportsScope(widget: DashboardWidgetConfig): boolean {
return Boolean(widget.settings?.scope);
}
getProviderLabel(provider: DashboardWidgetProvider): string {
this.languageTick();
if (provider === 'xtream') {
return this.translateText('WORKSPACE.DASHBOARD.XTREAM');
}
if (provider === 'stalker') {
return this.translateText('WORKSPACE.DASHBOARD.STALKER');
}
return this.translateText('WORKSPACE.DASHBOARD.M3U');
}
getScopeProviderCount(widget: DashboardWidgetConfig): number {
return (widget.settings?.scope ?? createDefaultWidgetScope()).providers
.length;
}
getScopeProviderSummary(widget: DashboardWidgetConfig): string {
this.languageTick();
const count = this.getScopeProviderCount(widget);
if (count === 0) {
return this.translateText('WORKSPACE.DASHBOARD.ALL_PROVIDERS');
}
return this.translateText('WORKSPACE.DASHBOARD.SELECTED_COUNT', {
count,
});
}
isAllProvidersScope(widget: DashboardWidgetConfig): boolean {
return this.getScopeProviderCount(widget) === 0;
}
isProviderSelected(
widget: DashboardWidgetConfig,
provider: DashboardWidgetProvider
): boolean {
return (
widget.settings?.scope ?? createDefaultWidgetScope()
).providers.includes(provider);
}
getScopePlaylists(widget: DashboardWidgetConfig) {
const scope = widget.settings?.scope ?? createDefaultWidgetScope();
const scopedProviders =
scope.providers.length === 0
? this.providerOptions
: scope.providers;
return this.playlists().filter((playlist) =>
scopedProviders.includes(this.getPlaylistProviderType(playlist))
);
}
isPlaylistSelected(
widget: DashboardWidgetConfig,
playlistId: string
): boolean {
return (
widget.settings?.scope ?? createDefaultWidgetScope()
).playlistIds.includes(playlistId);
}
toggleScopeProvider(
widgetId: string,
provider: DashboardWidgetProvider
): void {
this.layoutService.toggleWidgetScopeProvider(widgetId, provider);
}
toggleScopePlaylist(widgetId: string, playlistId: string): void {
this.layoutService.toggleWidgetScopePlaylist(widgetId, playlistId);
}
getPlaylistTitle(playlist: {
title?: string;
filename?: string;
url?: string;
_id?: string;
}): string {
this.languageTick();
return (
playlist.title ||
playlist.filename ||
playlist.url ||
playlist._id ||
this.translateText('WORKSPACE.DASHBOARD.UNTITLED_SOURCE')
);
}
getWidgetTitle(widget: DashboardWidgetConfig): string {
this.languageTick();
return this.translateText(widget.title);
@@ -253,25 +141,6 @@ export class WorkspaceDashboardComponent {
});
}
getScopePanelDescription(widget: DashboardWidgetConfig): string {
return this.translateText('WORKSPACE.DASHBOARD.WIDGET_SCOPE_SUMMARY', {
summary: this.getScopeProviderSummary(widget),
});
}
private getPlaylistProviderType(playlist: {
serverUrl?: string;
macAddress?: string;
}): DashboardWidgetProvider {
if (playlist.serverUrl) {
return 'xtream';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'm3u';
}
private translateText(
key: string,
params?: Record<string, string | number>