Proyecto App Linux
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
displayName: 'playlist-shared-ui',
|
||||
preset: '../../../../jest.preset.js',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
|
||||
coverageDirectory: '../../../../coverage/libs/playlist/shared/ui',
|
||||
transform: {
|
||||
'^.+\\.(ts|mjs|js|html)$': [
|
||||
'jest-preset-angular',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.spec.json',
|
||||
stringifyContentPathRegex: '\\.(html|svg)$',
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment',
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "playlist-shared-ui",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/playlist/shared/ui/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:playlist", "domain:m3u", "type:ui"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/playlist/shared/ui/jest.config.ts",
|
||||
"tsConfig": "libs/playlist/shared/ui/tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './lib/add-playlist-menu/playlist-type';
|
||||
export * from './lib/filter-sort-menu/filter-sort-menu.component';
|
||||
export * from './lib/playlist-switcher/playlist-switcher.component';
|
||||
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
|
||||
export * from './lib/recent-playlists/recent-playlists.component';
|
||||
export * from './lib/recent-playlists/empty-state/empty-state.component';
|
||||
@@ -0,0 +1 @@
|
||||
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
|
||||
@@ -0,0 +1,140 @@
|
||||
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
|
||||
<section class="filter-section">
|
||||
<div class="menu-section-header">
|
||||
<mat-icon class="section-icon">filter_list</mat-icon>
|
||||
<span>{{ 'HOME.FILTER_BY_TYPE' | translate }}</span>
|
||||
</div>
|
||||
@for (type of playlistTypes; track type.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="togglePlaylistType(type); $event.stopPropagation()"
|
||||
[class.active-option]="type.checked"
|
||||
>
|
||||
<mat-icon>{{ type.icon }}</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
type.translationKey | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="type.checked"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="sort-section">
|
||||
<div class="menu-section-header">
|
||||
<mat-icon class="section-icon">sort</mat-icon>
|
||||
<span>{{ 'HOME.SORT_BY' | translate }}</span>
|
||||
</div>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.NAME, SortOrder.ASC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.ASC)"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.NAME_ASC' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="isSortActive(SortBy.NAME, SortOrder.ASC)"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.NAME, SortOrder.DESC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.DESC)"
|
||||
>
|
||||
<mat-icon>sort_by_alpha</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.NAME_DESC' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.NAME, SortOrder.DESC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
|
||||
"
|
||||
>
|
||||
<mat-icon>date_range</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.NEWEST' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
|
||||
"
|
||||
>
|
||||
<mat-icon>date_range</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.OLDEST' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="
|
||||
setSortOptions(SortBy.CUSTOM, SortOrder.ASC);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
[class.active-option]="isSortActive(SortBy.CUSTOM, SortOrder.ASC)"
|
||||
>
|
||||
<mat-icon>drag_indicator</mat-icon>
|
||||
<span class="menu-item-text">{{
|
||||
'HOME.SORT_OPTIONS.CUSTOM_ORDER' | translate
|
||||
}}</span>
|
||||
<mat-icon
|
||||
class="check-icon"
|
||||
[class.check-visible]="
|
||||
isSortActive(SortBy.CUSTOM, SortOrder.ASC)
|
||||
"
|
||||
>check</mat-icon
|
||||
>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="menu-footer">
|
||||
<button mat-button (click)="closeMenu()">
|
||||
{{ 'CLOSE' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-menu>
|
||||
@@ -0,0 +1,87 @@
|
||||
.filter-section,
|
||||
.sort-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.menu-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
|
||||
.section-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: rgba(139, 92, 246, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .filter-sort-menu {
|
||||
.mat-mdc-menu-item {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
min-height: 40px !important;
|
||||
padding: 0 16px !important;
|
||||
|
||||
.mat-icon:first-child {
|
||||
margin-right: 0 !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-item-text {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
margin-left: auto !important;
|
||||
margin-right: 0 !important;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.check-visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.active-option {
|
||||
background-color: rgba(139, 92, 246, 0.12) !important;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 60%;
|
||||
background: rgba(139, 92, 246, 0.8);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
color: rgba(139, 92, 246, 1) !important;
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
padding: 4px 8px 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FilterActions, selectActiveTypeFilters } from 'm3u-state';
|
||||
import { SortBy, SortOrder, SortService } from 'services';
|
||||
|
||||
@Component({
|
||||
selector: 'app-filter-sort-menu',
|
||||
templateUrl: './filter-sort-menu.component.html',
|
||||
styleUrls: ['./filter-sort-menu.component.scss'],
|
||||
imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule],
|
||||
})
|
||||
export class FilterSortMenuComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly sortService = inject(SortService);
|
||||
|
||||
readonly menu = viewChild.required<MatMenu>('filterSortMenu');
|
||||
readonly menuTrigger = input<MatMenuTrigger>();
|
||||
readonly filterChanged = output<void>();
|
||||
|
||||
playlistTypes = [
|
||||
{
|
||||
id: 'm3u',
|
||||
icon: 'playlist_play',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'xtream',
|
||||
icon: 'cloud',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
id: 'stalker',
|
||||
icon: 'router',
|
||||
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
|
||||
checked: true,
|
||||
},
|
||||
];
|
||||
|
||||
private readonly selectedTypeFilters = this.store.selectSignal(
|
||||
selectActiveTypeFilters
|
||||
);
|
||||
|
||||
readonly SortBy = SortBy;
|
||||
readonly SortOrder = SortOrder;
|
||||
private readonly currentSortOptions = toSignal(
|
||||
this.sortService.getSortOptions(),
|
||||
{
|
||||
requireSync: true,
|
||||
}
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.selectedTypeFilters) {
|
||||
this.playlistTypes = this.playlistTypes.map((type) => {
|
||||
type.checked = this.selectedTypeFilters().includes(type.id);
|
||||
return type;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
togglePlaylistType(type: {
|
||||
id: string;
|
||||
icon: string;
|
||||
translationKey: string;
|
||||
checked: boolean;
|
||||
}) {
|
||||
const currentlySelectedCount = this.playlistTypes.filter(
|
||||
(item) => item.checked
|
||||
).length;
|
||||
|
||||
if (type.checked && currentlySelectedCount === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
type.checked = !type.checked;
|
||||
this.store.dispatch(
|
||||
FilterActions.setSelectedFilters({
|
||||
selectedFilters: this.playlistTypes
|
||||
.filter((item) => item.checked)
|
||||
.map((item) => item.id),
|
||||
})
|
||||
);
|
||||
this.filterChanged.emit();
|
||||
}
|
||||
|
||||
setSortOptions(by: SortBy, order: SortOrder): void {
|
||||
this.sortService.setSortOptions({ by, order });
|
||||
}
|
||||
|
||||
isSortActive(by: SortBy, order: SortOrder): boolean {
|
||||
const options = this.currentSortOptions();
|
||||
return options?.by === by && options?.order === order;
|
||||
}
|
||||
|
||||
get activeFiltersCount(): number {
|
||||
return this.playlistTypes.filter((type) => !type.checked).length;
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.menuTrigger()?.closeMenu();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
<div
|
||||
class="playlist-switcher-trigger"
|
||||
[class.is-open]="isMenuOpen()"
|
||||
matRipple
|
||||
[matRippleDisabled]="false"
|
||||
[matMenuTriggerFor]="playlistMenu"
|
||||
#menuTrigger="matMenuTrigger"
|
||||
#triggerElement
|
||||
(menuOpened)="onMenuOpened()"
|
||||
(menuClosed)="onMenuClosed()"
|
||||
>
|
||||
<div class="trigger-icon-container">
|
||||
<mat-icon class="trigger-playlist-icon">playlist_play</mat-icon>
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<div class="name">{{ displayTitle() }}</div>
|
||||
<div class="channels-count">{{ subtitle() }}</div>
|
||||
</div>
|
||||
@if (canRefreshActivePlaylist()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="trigger-refresh-button"
|
||||
[disabled]="isRefreshingActivePlaylist()"
|
||||
(click)="onRefreshClick($event)"
|
||||
(mousedown)="$event.stopPropagation()"
|
||||
[matTooltip]="'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon
|
||||
class="trigger-refresh-icon"
|
||||
[class.is-spinning]="isRefreshingActivePlaylist()"
|
||||
>sync</mat-icon
|
||||
>
|
||||
</button>
|
||||
}
|
||||
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
|
||||
expand_more
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
<mat-menu
|
||||
#playlistMenu="matMenu"
|
||||
panelClass="playlist-switcher-menu"
|
||||
hasBackdrop
|
||||
backdropClass="playlist-switcher-menu-backdrop"
|
||||
overlapTrigger="false"
|
||||
xPosition="after"
|
||||
yPosition="below"
|
||||
>
|
||||
<div class="menu-header">
|
||||
<mat-icon class="menu-header-icon">playlist_play</mat-icon>
|
||||
<span class="menu-header-title">{{
|
||||
'HOME.PLAYLISTS.MY_PLAYLISTS' | translate
|
||||
}}</span>
|
||||
@if (hasSearchToggle()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="search-toggle-btn"
|
||||
[class.is-active]="searchExpanded()"
|
||||
(click)="toggleSearchField($event)"
|
||||
[attr.aria-label]="
|
||||
'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showSearchField()) {
|
||||
<div class="search-container" (click)="$event.stopPropagation()">
|
||||
<mat-icon class="search-icon">search</mat-icon>
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
class="search-input"
|
||||
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="setSearchQuery($event)"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="clear-btn"
|
||||
(click)="setSearchQuery('')"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showTypeFilters()) {
|
||||
<div class="type-filters" (click)="$event.stopPropagation()">
|
||||
@if (hasType('m3u')) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('m3u')"
|
||||
(click)="togglePlaylistTypeFilter('m3u', $event)"
|
||||
>
|
||||
M3U
|
||||
</button>
|
||||
}
|
||||
@if (hasType('stalker')) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('stalker')"
|
||||
(click)="togglePlaylistTypeFilter('stalker', $event)"
|
||||
>
|
||||
Stalker
|
||||
</button>
|
||||
}
|
||||
@if (hasType('xtream')) {
|
||||
<button
|
||||
type="button"
|
||||
class="type-filter-chip"
|
||||
[class.selected]="isTypeFilterSelected('xtream')"
|
||||
(click)="togglePlaylistTypeFilter('xtream', $event)"
|
||||
>
|
||||
Xtream
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="menu-content">
|
||||
@for (playlist of filteredPlaylists(); track playlist._id) {
|
||||
<div
|
||||
class="playlist-item"
|
||||
[class.selected]="isSelected(playlist)"
|
||||
(click)="selectPlaylist(playlist)"
|
||||
>
|
||||
<!-- @if (isSelected(playlist)) {
|
||||
<mat-icon class="selected-indicator">check</mat-icon>
|
||||
} @else {
|
||||
<div class="selected-indicator-placeholder"></div>
|
||||
} -->
|
||||
|
||||
<div class="playlist-icon-container">
|
||||
<mat-icon class="playlist-type-icon">
|
||||
{{ getPlaylistIcon(playlist) }}
|
||||
</mat-icon>
|
||||
@if (playlist.serverUrl) {
|
||||
<span
|
||||
class="status-dot"
|
||||
[class]="getStatusClass(playlist._id)"
|
||||
></span>
|
||||
}
|
||||
@if (
|
||||
playlist.autoRefresh &&
|
||||
!playlist.serverUrl &&
|
||||
!playlist.macAddress
|
||||
) {
|
||||
<mat-icon class="auto-refresh-indicator"
|
||||
>autorenew</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="playlist-details">
|
||||
<span class="playlist-title">
|
||||
{{ playlist.title || playlist.filename }}
|
||||
</span>
|
||||
<span class="playlist-meta">
|
||||
{{ getPlaylistMetaLabel(playlist) }}
|
||||
@if (playlist.updateDate) {
|
||||
<span class="separator">·</span>
|
||||
{{
|
||||
playlist.updateDate
|
||||
| date
|
||||
: 'MMM d, HH:mm'
|
||||
: undefined
|
||||
: currentLocale()
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="item-action-trigger"
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="itemMenu"
|
||||
[matMenuTriggerData]="{ playlist: playlist }"
|
||||
(click)="$event.stopPropagation()"
|
||||
aria-label="Playlist actions"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-state">
|
||||
<mat-icon>playlist_add</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showAddPlaylist() || showAccountInfo()) {
|
||||
<div class="context-actions-section">
|
||||
@if (showAddPlaylist()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAddPlaylist()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (showAccountInfo()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAccountInfo()"
|
||||
>
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #itemMenu="matMenu" class="playlist-item-action-menu">
|
||||
<ng-template matMenuContent let-playlist="playlist">
|
||||
<button mat-menu-item (click)="openPlaylistInfoFor(playlist, $event)">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'WORKSPACE.SHELL.PLAYLIST_INFO' | translate }}</span>
|
||||
</button>
|
||||
@if (canRefreshPlaylist(playlist)) {
|
||||
<button mat-menu-item (click)="refreshPlaylistFor(playlist, $event)">
|
||||
<mat-icon>sync</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.REFRESH_PLAYLIST' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
class="danger"
|
||||
(click)="removePlaylistFor(playlist, $event)"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE' | translate }}</span>
|
||||
</button>
|
||||
</ng-template>
|
||||
</mat-menu>
|
||||
@@ -0,0 +1,608 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.playlist-switcher-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: color-mix(in srgb, var(--app-on-surface) 5%, transparent);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 36%, transparent);
|
||||
app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 12%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-outline-variant) 72%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-icon-container {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 8%,
|
||||
transparent
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trigger-playlist-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.name {
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.channels-count {
|
||||
font-size: 0.74em;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-refresh-button {
|
||||
margin-left: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
line-height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--mat-sys-on-surface);
|
||||
background: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-button-touch-target {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple,
|
||||
::ng-deep .mat-mdc-focus-indicator {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger-refresh-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.is-spinning {
|
||||
animation: switcher-refresh-spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes switcher-refresh-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-left: 4px;
|
||||
|
||||
&.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel {
|
||||
--mat-menu-container-color: var(--mat-sys-surface-container-high);
|
||||
width: min(
|
||||
var(--playlist-switcher-overlay-width, 400px),
|
||||
calc(100vw - 24px)
|
||||
) !important;
|
||||
min-width: min(
|
||||
var(--playlist-switcher-overlay-width, 400px),
|
||||
calc(100vw - 24px)
|
||||
) !important;
|
||||
max-width: min(
|
||||
var(--playlist-switcher-overlay-width, 400px),
|
||||
calc(100vw - 24px)
|
||||
) !important;
|
||||
margin-top: -1px !important;
|
||||
border-radius: 0 0 12px 12px !important;
|
||||
border: 1px solid var(--mat-sys-outline) !important;
|
||||
border-top: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: var(--mat-menu-container-color) !important;
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 0 !important;
|
||||
background: var(--mat-menu-container-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 6px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: var(--mat-menu-container-color);
|
||||
|
||||
.menu-header-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.menu-header-icon {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.search-toggle-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.8;
|
||||
padding: 0;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin: 0 8px 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 6%,
|
||||
transparent
|
||||
);
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-container:focus-within {
|
||||
border-color: var(--mat-sys-primary);
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
}
|
||||
|
||||
.search-container .search-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.search-container .search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 0.86em;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-container .search-input::placeholder {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.search-container .clear-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-container .clear-btn mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 8px 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.type-filter-chip {
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mat-sys-surface-container-high) 78%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--app-selection-border, var(--app-selection-color));
|
||||
background: var(
|
||||
--app-selection-surface-strong,
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
color: var(--app-selection-color, var(--mat-sys-primary));
|
||||
/* box-shadow:
|
||||
inset 0 0 0 1px
|
||||
color-mix(in srgb, var(--app-selection-color) 18%, transparent),
|
||||
0 0 0 1px
|
||||
color-mix(in srgb, var(--app-selection-color) 12%, transparent),
|
||||
0 0 10px var(--app-selection-glow, transparent); */
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-border, var(--mat-sys-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
background: color-mix(in srgb, #000 22%, transparent);
|
||||
border-top: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 14%, transparent);
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 12px;
|
||||
min-height: 44px;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item.selected {
|
||||
background: var(--app-selection-surface, rgba(139, 92, 246, 0.1));
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: var(--app-selection-color, #a78bfa);
|
||||
}
|
||||
|
||||
.playlist-title,
|
||||
.playlist-meta {
|
||||
color: var(--app-selection-color, #a78bfa);
|
||||
}
|
||||
}
|
||||
|
||||
.item-action-trigger {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
flex-shrink: 0;
|
||||
color: var(--mat-sys-on-surface-variant);padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item:hover .item-action-trigger,
|
||||
.playlist-item.selected .item-action-trigger,
|
||||
.item-action-trigger:focus-visible,
|
||||
.item-action-trigger[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
::ng-deep .playlist-item-action-menu {
|
||||
.danger,
|
||||
.danger mat-icon {
|
||||
color: var(--mat-sys-error, #f44336);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item:hover:not(.selected) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 10%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface) 6%,
|
||||
transparent
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.playlist-type-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
border: 2px solid var(--mat-menu-container-color, #2a2a2e);
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #4caf50;
|
||||
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #f44336;
|
||||
box-shadow: 0 0 6px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #ff9800;
|
||||
box-shadow: 0 0 6px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
background-color: #666666;
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
font-size: 12px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
color: var(--mat-sys-primary);
|
||||
background-color: var(--mat-menu-container-color, var(--mat-sys-surface));
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 0.92em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 0.73em;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.separator {
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
gap: 12px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
.context-actions-section {
|
||||
padding: 6px 8px 8px;
|
||||
background: var(--mat-menu-container-color);
|
||||
|
||||
.context-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font: inherit;
|
||||
font-size: 0.86em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.playlist-info .name {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
import { signal } from '@angular/core';
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
flushMicrotasks,
|
||||
tick,
|
||||
} from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistContextFacade,
|
||||
PlaylistRefreshActionService,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import { DialogService } from 'components';
|
||||
import { DatabaseService, PortalStatusService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
|
||||
|
||||
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
|
||||
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
|
||||
|
||||
function createPlaylist(
|
||||
overrides: Partial<PlaylistMeta> & { _id: string }
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: overrides._id,
|
||||
title: overrides.title ?? overrides.filename ?? overrides._id,
|
||||
filename: overrides.filename,
|
||||
count: overrides.count ?? 0,
|
||||
importDate:
|
||||
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
|
||||
autoRefresh: overrides.autoRefresh ?? false,
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('PlaylistSwitcherComponent', () => {
|
||||
let fixture: ComponentFixture<PlaylistSwitcherComponent>;
|
||||
let component: PlaylistSwitcherComponent;
|
||||
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
|
||||
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
|
||||
let playlistContext: {
|
||||
playlists: typeof playlistsSignal;
|
||||
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
|
||||
resolvedPlaylistId: typeof resolvedPlaylistIdSignal;
|
||||
activePlaylist: typeof activePlaylistSignal;
|
||||
selectPlaylist: jest.Mock;
|
||||
};
|
||||
let portalStatusService: {
|
||||
checkPortalStatus: jest.Mock;
|
||||
getStatusClass: jest.Mock;
|
||||
};
|
||||
let refreshActionService: {
|
||||
canRefresh: jest.Mock;
|
||||
refresh: jest.Mock;
|
||||
};
|
||||
let dialog: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let dialogService: {
|
||||
openConfirmDialog: jest.Mock;
|
||||
};
|
||||
let databaseService: {
|
||||
createOperationId: jest.Mock;
|
||||
deletePlaylist: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: {
|
||||
dispatch: jest.Mock;
|
||||
};
|
||||
|
||||
const m3uPlaylist = createPlaylist({
|
||||
_id: 'm3u-1',
|
||||
count: 24,
|
||||
importDate: '2026-04-05T09:00:00.000Z',
|
||||
title: 'Morning M3U',
|
||||
url: 'http://example.test/m3u.m3u',
|
||||
});
|
||||
const stalkerPlaylist = createPlaylist({
|
||||
_id: 'stalker-1',
|
||||
importDate: '2026-04-05T11:00:00.000Z',
|
||||
macAddress: '00:1A:79:00:00:01',
|
||||
portalUrl: 'http://127.0.0.1:3210/portal.php',
|
||||
title: 'Living Room Stalker',
|
||||
});
|
||||
const xtreamPlaylist = createPlaylist({
|
||||
_id: 'xtream-1',
|
||||
importDate: '2026-04-05T12:00:00.000Z',
|
||||
password: 'pass1',
|
||||
serverUrl: 'http://127.0.0.1:3211',
|
||||
title: 'Cinema Xtream',
|
||||
updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(),
|
||||
username: 'user1',
|
||||
});
|
||||
|
||||
async function createComponent(): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlaylistSwitcherComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: playlistContext,
|
||||
},
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: portalStatusService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistRefreshActionService,
|
||||
useValue: refreshActionService,
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: dialog,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: Store,
|
||||
useValue: store,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PlaylistSwitcherComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('currentTitle', '');
|
||||
fixture.componentRef.setInput('subtitle', '3 playlists');
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
playlistsSignal = signal([m3uPlaylist, stalkerPlaylist, xtreamPlaylist]);
|
||||
resolvedPlaylistIdSignal = signal<string | null>(xtreamPlaylist._id);
|
||||
activePlaylistSignal = signal<PlaylistMeta | null>(xtreamPlaylist);
|
||||
|
||||
playlistContext = {
|
||||
playlists: playlistsSignal,
|
||||
allPlaylistsLoaded: signal(true),
|
||||
resolvedPlaylistId: resolvedPlaylistIdSignal,
|
||||
activePlaylist: activePlaylistSignal,
|
||||
selectPlaylist: jest.fn((playlist: PlaylistMeta) => {
|
||||
resolvedPlaylistIdSignal.set(playlist._id);
|
||||
activePlaylistSignal.set(playlist);
|
||||
}),
|
||||
};
|
||||
portalStatusService = {
|
||||
checkPortalStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('active')
|
||||
.mockResolvedValueOnce('inactive'),
|
||||
getStatusClass: jest.fn((status: string) => `status-${status}`),
|
||||
};
|
||||
refreshActionService = {
|
||||
canRefresh: jest.fn(
|
||||
(playlist: PlaylistMeta) =>
|
||||
Boolean(
|
||||
playlist.serverUrl || playlist.url || playlist.filePath
|
||||
)
|
||||
),
|
||||
refresh: jest.fn(),
|
||||
};
|
||||
dialog = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
databaseService = {
|
||||
createOperationId: jest.fn(),
|
||||
deletePlaylist: jest.fn(),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
store = {
|
||||
dispatch: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads persisted search and type filters on startup and filters playlists accordingly', async () => {
|
||||
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room');
|
||||
localStorage.setItem(
|
||||
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
m3u: false,
|
||||
stalker: true,
|
||||
xtream: true,
|
||||
})
|
||||
);
|
||||
|
||||
await createComponent();
|
||||
|
||||
expect(component.searchQuery()).toBe('room');
|
||||
expect(component.isTypeFilterSelected('m3u')).toBe(false);
|
||||
expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]);
|
||||
});
|
||||
|
||||
it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(component.filteredPlaylists().map((playlist) => playlist._id)).toEqual([
|
||||
xtreamPlaylist._id,
|
||||
stalkerPlaylist._id,
|
||||
m3uPlaylist._id,
|
||||
]);
|
||||
|
||||
component.togglePlaylistTypeFilter('m3u');
|
||||
component.togglePlaylistTypeFilter('stalker');
|
||||
expect(component.playlistTypeFilters()).toEqual({
|
||||
m3u: false,
|
||||
stalker: false,
|
||||
xtream: true,
|
||||
});
|
||||
|
||||
component.togglePlaylistTypeFilter('xtream');
|
||||
expect(component.playlistTypeFilters()).toEqual({
|
||||
m3u: false,
|
||||
stalker: false,
|
||||
xtream: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects a playlist, closes the menu, and emits the selected id', async () => {
|
||||
await createComponent();
|
||||
|
||||
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
|
||||
const selectedIds: string[] = [];
|
||||
component.playlistSelected.subscribe((playlistId) => {
|
||||
selectedIds.push(playlistId);
|
||||
});
|
||||
|
||||
component.selectPlaylist(stalkerPlaylist);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(closeMenuSpy).toHaveBeenCalledTimes(1);
|
||||
expect(playlistContext.selectPlaylist).toHaveBeenCalledWith(
|
||||
stalkerPlaylist
|
||||
);
|
||||
expect(selectedIds).toEqual([stalkerPlaylist._id]);
|
||||
expect(component.isSelected(stalkerPlaylist)).toBe(true);
|
||||
});
|
||||
|
||||
it('emits playlist and account info requests through the context actions', async () => {
|
||||
await createComponent();
|
||||
|
||||
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
|
||||
const playlistInfoSpy = jest.fn();
|
||||
const accountInfoSpy = jest.fn();
|
||||
component.playlistInfoRequested.subscribe(playlistInfoSpy);
|
||||
component.accountInfoRequested.subscribe(accountInfoSpy);
|
||||
|
||||
component.requestPlaylistInfo();
|
||||
component.requestAccountInfo();
|
||||
|
||||
expect(closeMenuSpy).toHaveBeenCalledTimes(2);
|
||||
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
|
||||
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
|
||||
await createComponent();
|
||||
|
||||
const triggerElement = component.triggerElement().nativeElement;
|
||||
jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 320,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width: 320,
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
component.onMenuOpened();
|
||||
flushMicrotasks();
|
||||
tick();
|
||||
|
||||
expect(component.isMenuOpen()).toBe(true);
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--playlist-switcher-overlay-width'
|
||||
)
|
||||
).toBe('400px');
|
||||
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1);
|
||||
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
|
||||
xtreamPlaylist.serverUrl,
|
||||
xtreamPlaylist.username,
|
||||
xtreamPlaylist.password
|
||||
);
|
||||
expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe('active');
|
||||
expect(component.getStatusClass(xtreamPlaylist._id)).toBe('status-active');
|
||||
|
||||
component.onMenuClosed();
|
||||
expect(component.isMenuOpen()).toBe(false);
|
||||
expect(
|
||||
document.documentElement.style.getPropertyValue(
|
||||
'--playlist-switcher-overlay-width'
|
||||
)
|
||||
).toBe('');
|
||||
}));
|
||||
|
||||
it('falls back to the active playlist title when no explicit current title is provided', async () => {
|
||||
await createComponent();
|
||||
|
||||
expect(component.displayTitle()).toBe('Cinema Xtream');
|
||||
|
||||
resolvedPlaylistIdSignal.set(null);
|
||||
activePlaylistSignal.set(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.displayTitle()).toBe('Select playlist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
import { DatePipe, DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatRippleModule } from '@angular/material/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { DialogService } from 'components';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import {
|
||||
PlaylistContextFacade,
|
||||
PlaylistRefreshActionService,
|
||||
} from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
DatabaseService,
|
||||
PortalStatus,
|
||||
PortalStatusService,
|
||||
} from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
import { startWith } from 'rxjs';
|
||||
import { PlaylistInfoComponent } from '../recent-playlists/playlist-info/playlist-info.component';
|
||||
|
||||
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
|
||||
|
||||
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
|
||||
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
|
||||
const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
|
||||
m3u: true,
|
||||
stalker: true,
|
||||
xtream: true,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher',
|
||||
templateUrl: './playlist-switcher.component.html',
|
||||
styleUrls: ['./playlist-switcher.component.scss'],
|
||||
imports: [
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
MatDivider,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatRippleModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PlaylistSwitcherComponent {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly refreshAction = inject(PlaylistRefreshActionService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly store = inject(Store);
|
||||
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly currentTitle = input.required<string>();
|
||||
readonly subtitle = input<string>('');
|
||||
readonly showPlaylistInfo = input(false);
|
||||
readonly showAccountInfo = input(false);
|
||||
readonly showAddPlaylist = input(false);
|
||||
readonly canRefreshActivePlaylist = input(false);
|
||||
readonly isRefreshingActivePlaylist = input(false);
|
||||
readonly playlistSelected = output<string>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly addPlaylistRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
|
||||
onRefreshClick(event: Event): void {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.refreshPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
|
||||
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
|
||||
readonly triggerElement =
|
||||
viewChild.required<ElementRef<HTMLElement>>('triggerElement');
|
||||
readonly searchInput =
|
||||
viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
||||
|
||||
readonly isMenuOpen = signal(false);
|
||||
readonly searchQuery = signal(this.readPersistedSearchQuery());
|
||||
readonly playlistTypeFilters = signal(this.readPersistedTypeFilters());
|
||||
readonly searchExpanded = signal(false);
|
||||
private static readonly SEARCH_AUTO_VISIBLE_THRESHOLD = 5;
|
||||
|
||||
readonly playlists = this.playlistContext.playlists;
|
||||
readonly allPlaylistsLoaded = this.playlistContext.allPlaylistsLoaded;
|
||||
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
||||
readonly activePlaylist = this.playlistContext.activePlaylist;
|
||||
readonly filteredPlaylists = computed(() => {
|
||||
const query = this.searchQuery().toLowerCase().trim();
|
||||
const filters = this.playlistTypeFilters();
|
||||
const allPlaylists = this.playlists();
|
||||
const filteredByType = allPlaylists.filter((playlist) => {
|
||||
const playlistType = this.getPlaylistFilterType(playlist);
|
||||
return filters[playlistType];
|
||||
});
|
||||
const filtered = query
|
||||
? filteredByType.filter(
|
||||
(playlist) =>
|
||||
playlist.title?.toLowerCase().includes(query) ||
|
||||
playlist.filename?.toLowerCase().includes(query)
|
||||
)
|
||||
: filteredByType;
|
||||
|
||||
return [...filtered].sort(
|
||||
(a, b) =>
|
||||
new Date(b.importDate ?? 0).getTime() -
|
||||
new Date(a.importDate ?? 0).getTime()
|
||||
);
|
||||
});
|
||||
readonly displayTitle = computed(() => {
|
||||
if (!this.activePlaylistId()) {
|
||||
return 'Select playlist';
|
||||
}
|
||||
|
||||
return (
|
||||
this.currentTitle() ||
|
||||
this.activePlaylist()?.title ||
|
||||
this.activePlaylist()?.filename ||
|
||||
this.activePlaylist()?.url ||
|
||||
this.activePlaylist()?.portalUrl ||
|
||||
'Untitled playlist'
|
||||
);
|
||||
});
|
||||
|
||||
readonly availablePlaylistTypes = computed(() => {
|
||||
const types = new Set<PlaylistFilterType>();
|
||||
for (const playlist of this.playlists()) {
|
||||
types.add(this.getPlaylistFilterType(playlist));
|
||||
}
|
||||
return types;
|
||||
});
|
||||
readonly showTypeFilters = computed(
|
||||
() => this.availablePlaylistTypes().size > 1
|
||||
);
|
||||
readonly hasSearchToggle = computed(
|
||||
() =>
|
||||
this.playlists().length <=
|
||||
PlaylistSwitcherComponent.SEARCH_AUTO_VISIBLE_THRESHOLD
|
||||
);
|
||||
readonly showSearchField = computed(
|
||||
() => !this.hasSearchToggle() || this.searchExpanded()
|
||||
);
|
||||
|
||||
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.clearMenuOverlayWidth();
|
||||
this.clearSearchFocusTimeout();
|
||||
});
|
||||
}
|
||||
|
||||
onMenuOpened(): void {
|
||||
this.isMenuOpen.set(true);
|
||||
this.syncMenuOverlayWidthToTrigger();
|
||||
if (this.showSearchField()) {
|
||||
this.focusSearchField();
|
||||
}
|
||||
void this.checkPortalStatuses(this.playlists());
|
||||
}
|
||||
|
||||
onMenuClosed(): void {
|
||||
this.isMenuOpen.set(false);
|
||||
this.clearMenuOverlayWidth();
|
||||
this.clearSearchFocusTimeout();
|
||||
if (this.hasSearchToggle()) {
|
||||
this.searchExpanded.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSearchField(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
const next = !this.searchExpanded();
|
||||
this.searchExpanded.set(next);
|
||||
if (next) {
|
||||
this.focusSearchField();
|
||||
}
|
||||
}
|
||||
|
||||
setSearchQuery(value: string): void {
|
||||
this.searchQuery.set(value);
|
||||
this.writePersistedSearchQuery(value);
|
||||
}
|
||||
|
||||
togglePlaylistTypeFilter(type: PlaylistFilterType, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
const current = this.playlistTypeFilters();
|
||||
const next = {
|
||||
...current,
|
||||
[type]: !current[type],
|
||||
};
|
||||
|
||||
if (!next.m3u && !next.stalker && !next.xtream) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistTypeFilters.set(next);
|
||||
this.writePersistedTypeFilters(next);
|
||||
}
|
||||
|
||||
isTypeFilterSelected(type: PlaylistFilterType): boolean {
|
||||
return this.playlistTypeFilters()[type];
|
||||
}
|
||||
|
||||
selectPlaylist(playlist: PlaylistMeta): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.playlistContext.selectPlaylist(playlist);
|
||||
this.playlistSelected.emit(playlist._id);
|
||||
}
|
||||
|
||||
requestPlaylistInfo(): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.playlistInfoRequested.emit();
|
||||
}
|
||||
|
||||
requestAccountInfo(): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.accountInfoRequested.emit();
|
||||
}
|
||||
|
||||
requestAddPlaylist(): void {
|
||||
this.menuTrigger().closeMenu();
|
||||
this.addPlaylistRequested.emit();
|
||||
}
|
||||
|
||||
hasType(type: PlaylistFilterType): boolean {
|
||||
return this.availablePlaylistTypes().has(type);
|
||||
}
|
||||
|
||||
canRefreshPlaylist(playlist: PlaylistMeta): boolean {
|
||||
return this.refreshAction.canRefresh(playlist);
|
||||
}
|
||||
|
||||
openPlaylistInfoFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.dialog.open(PlaylistInfoComponent, { data: playlist });
|
||||
}
|
||||
|
||||
refreshPlaylistFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.refreshAction.refresh(playlist);
|
||||
}
|
||||
|
||||
removePlaylistFor(playlist: PlaylistMeta, event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
this.menuTrigger().closeMenu();
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: () => this.removePlaylistConfirmed(playlist),
|
||||
});
|
||||
}
|
||||
|
||||
private async removePlaylistConfirmed(
|
||||
playlist: PlaylistMeta
|
||||
): Promise<void> {
|
||||
const operationId = playlist.serverUrl
|
||||
? this.databaseService.createOperationId('playlist-delete')
|
||||
: undefined;
|
||||
|
||||
const deleted = await this.databaseService.deletePlaylist(
|
||||
playlist._id,
|
||||
operationId ? { operationId } : undefined
|
||||
);
|
||||
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({ playlistId: playlist._id })
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
}
|
||||
|
||||
getPlaylistIcon(playlist: PlaylistMeta): string {
|
||||
if (playlist.macAddress) {
|
||||
return 'dashboard';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'public';
|
||||
}
|
||||
if (playlist.url) {
|
||||
return 'cloud';
|
||||
}
|
||||
return 'folder';
|
||||
}
|
||||
|
||||
getPlaylistTypeLabel(playlist: PlaylistMeta): string {
|
||||
if (playlist.macAddress) {
|
||||
return 'Stalker Portal';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'Xtream Code';
|
||||
}
|
||||
return `${playlist.count} channels`;
|
||||
}
|
||||
|
||||
getPlaylistMetaLabel(playlist: PlaylistMeta): string {
|
||||
const count = playlist.count ?? 0;
|
||||
const channelsLabel = this.translate.instant(
|
||||
'HOME.PLAYLISTS.CHANNELS_COUNT',
|
||||
{ count }
|
||||
);
|
||||
const fallback = `${count} channels`;
|
||||
const countLabel =
|
||||
channelsLabel && channelsLabel !== 'HOME.PLAYLISTS.CHANNELS_COUNT'
|
||||
? channelsLabel
|
||||
: fallback;
|
||||
|
||||
if (playlist.macAddress) {
|
||||
return 'Stalker Portal';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'Xtream Code';
|
||||
}
|
||||
return countLabel;
|
||||
}
|
||||
|
||||
getStatusClass(playlistId: string): string {
|
||||
const status = this.portalStatuses().get(playlistId);
|
||||
return this.portalStatusService.getStatusClass(status || 'unavailable');
|
||||
}
|
||||
|
||||
isSelected(playlist: PlaylistMeta): boolean {
|
||||
return this.activePlaylistId() === playlist._id;
|
||||
}
|
||||
|
||||
private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType {
|
||||
if (playlist.macAddress) {
|
||||
return 'stalker';
|
||||
}
|
||||
if (playlist.serverUrl) {
|
||||
return 'xtream';
|
||||
}
|
||||
return 'm3u';
|
||||
}
|
||||
|
||||
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
|
||||
const statusPromises = playlists
|
||||
.filter(
|
||||
(playlist) =>
|
||||
playlist.serverUrl && playlist.username && playlist.password
|
||||
)
|
||||
.map(async (playlist) => {
|
||||
try {
|
||||
const status =
|
||||
await this.portalStatusService.checkPortalStatus(
|
||||
playlist.serverUrl,
|
||||
playlist.username,
|
||||
playlist.password
|
||||
);
|
||||
return { id: playlist._id, status };
|
||||
} catch {
|
||||
return {
|
||||
id: playlist._id,
|
||||
status: 'unavailable' as PortalStatus,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(statusPromises);
|
||||
const statusMap = new Map(
|
||||
results.map((result) => [result.id, result.status])
|
||||
);
|
||||
this.portalStatuses.set(statusMap);
|
||||
}
|
||||
|
||||
private syncMenuOverlayWidthToTrigger(): void {
|
||||
const triggerWidth = Math.round(
|
||||
this.triggerElement().nativeElement.getBoundingClientRect().width
|
||||
);
|
||||
const overlayWidth = Math.max(triggerWidth, 400);
|
||||
this.document.documentElement.style.setProperty(
|
||||
'--playlist-switcher-overlay-width',
|
||||
`${overlayWidth}px`
|
||||
);
|
||||
}
|
||||
|
||||
private clearMenuOverlayWidth(): void {
|
||||
this.document.documentElement.style.removeProperty(
|
||||
'--playlist-switcher-overlay-width'
|
||||
);
|
||||
}
|
||||
|
||||
private focusSearchField(): void {
|
||||
this.clearSearchFocusTimeout();
|
||||
this.focusSearchTimeoutId = setTimeout(() => {
|
||||
const input = this.searchInput()?.nativeElement;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.focus();
|
||||
input.select();
|
||||
this.focusSearchTimeoutId = null;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private clearSearchFocusTimeout(): void {
|
||||
if (this.focusSearchTimeoutId !== null) {
|
||||
clearTimeout(this.focusSearchTimeoutId);
|
||||
this.focusSearchTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private readPersistedSearchQuery(): string {
|
||||
try {
|
||||
return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private writePersistedSearchQuery(value: string): void {
|
||||
try {
|
||||
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, value);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
|
||||
private readPersistedTypeFilters(): Record<PlaylistFilterType, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(PLAYLIST_TYPE_FILTER_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<
|
||||
Record<PlaylistFilterType, boolean>
|
||||
>;
|
||||
const next = {
|
||||
m3u: parsed.m3u !== false,
|
||||
stalker: parsed.stalker !== false,
|
||||
xtream: parsed.xtream !== false,
|
||||
};
|
||||
|
||||
if (!next.m3u && !next.stalker && !next.xtream) {
|
||||
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
|
||||
}
|
||||
|
||||
return next;
|
||||
} catch {
|
||||
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
|
||||
}
|
||||
}
|
||||
|
||||
private writePersistedTypeFilters(
|
||||
filters: Record<PlaylistFilterType, boolean>
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
|
||||
JSON.stringify(filters)
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage write failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
@switch (type()) {
|
||||
@case ('welcome-dashboard') {
|
||||
<div class="empty-state welcome-dashboard">
|
||||
<div class="welcome-dashboard__hero">
|
||||
<div class="welcome-dashboard__illustration-container">
|
||||
<svg class="welcome-dashboard__illustration" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="40" y="40" width="432" height="432" rx="80" class="icon-background"/>
|
||||
<rect x="134" y="387" width="244" height="18" rx="4" class="monitor-base"/>
|
||||
<rect x="238" y="347" width="36" height="40" rx="4" class="monitor-stand"/>
|
||||
<rect x="88" y="102" width="336" height="245" rx="12" stroke="currentColor" stroke-width="12" fill="none" class="screen-border"/>
|
||||
<circle cx="256" cy="280" r="16" class="wifi-dot"/>
|
||||
<path d="M 220 250 Q 256 220, 292 250" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-1"/>
|
||||
<path d="M 200 230 Q 256 185, 312 230" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-2"/>
|
||||
<path d="M 180 210 Q 256 150, 332 210" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@if (productBranding?.xtreamOnlyUi) {
|
||||
<h2 class="welcome-dashboard__title">
|
||||
{{ 'HOME.KIRATV.WELCOME_TITLE' | translate }}
|
||||
</h2>
|
||||
<p class="welcome-dashboard__tagline">
|
||||
{{ 'HOME.KIRATV.WELCOME_TAGLINE' | translate }}
|
||||
</p>
|
||||
<p class="welcome-dashboard__description">
|
||||
{{ 'HOME.KIRATV.WELCOME_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
} @else {
|
||||
<h2 class="welcome-dashboard__title">
|
||||
{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}
|
||||
</h2>
|
||||
<p class="welcome-dashboard__tagline">
|
||||
{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_TAGLINE' | translate }}
|
||||
</p>
|
||||
<p class="welcome-dashboard__description">
|
||||
{{ 'HOME.PLAYLISTS.DASHBOARD_WELCOME_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
type="button"
|
||||
class="welcome-dashboard__cta"
|
||||
(click)="onAddPlaylist()"
|
||||
>
|
||||
<mat-icon>add_circle</mat-icon>
|
||||
@if (productBranding?.xtreamOnlyUi) {
|
||||
{{ 'HOME.KIRATV.ADD_FIRST_CTA' | translate }}
|
||||
} @else {
|
||||
{{ 'HOME.PLAYLISTS.ADD_YOUR_FIRST' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="welcome-dashboard__cards" role="list">
|
||||
@for (card of featureCards; track card.titleKey) {
|
||||
@if (isFeatureCardVisible(card)) {
|
||||
<li class="feature-card">
|
||||
<div class="feature-card__icon-wrap">
|
||||
<mat-icon class="feature-card__icon">{{ card.icon }}</mat-icon>
|
||||
</div>
|
||||
<h3 class="feature-card__title">{{ card.titleKey | translate }}</h3>
|
||||
<p class="feature-card__desc">{{ card.descKey | translate }}</p>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@case ('welcome-sources') {
|
||||
<div class="empty-state welcome-sources">
|
||||
<header class="welcome-sources__header">
|
||||
<div class="welcome-sources__icon-wrap">
|
||||
<mat-icon class="welcome-sources__icon">playlist_add</mat-icon>
|
||||
</div>
|
||||
@if (productBranding?.xtreamOnlyUi) {
|
||||
<h2 class="welcome-sources__title">
|
||||
{{ 'HOME.KIRATV.WELCOME_TITLE' | translate }}
|
||||
</h2>
|
||||
<p class="welcome-sources__body">
|
||||
{{ 'HOME.KIRATV.WELCOME_DESCRIPTION' | translate }}
|
||||
</p>
|
||||
} @else {
|
||||
<h2 class="welcome-sources__title">
|
||||
{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_TITLE' | translate }}
|
||||
</h2>
|
||||
<p class="welcome-sources__body">
|
||||
{{ 'HOME.PLAYLISTS.SOURCES_WELCOME_BODY' | translate }}
|
||||
</p>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div class="welcome-sources__grid">
|
||||
@for (card of visibleSourceCards(); track card.type) {
|
||||
<article class="source-card">
|
||||
<div class="source-card__head">
|
||||
<mat-icon class="source-card__icon">{{ card.icon }}</mat-icon>
|
||||
<h3 class="source-card__name">{{ card.nameKey | translate }}</h3>
|
||||
</div>
|
||||
<p class="source-card__needs">{{ card.needsKey | translate }}</p>
|
||||
<ul class="source-card__chips" role="list">
|
||||
@for (chip of card.contentKeys; track chip) {
|
||||
<li class="source-card__chip">{{ chip | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
<button
|
||||
mat-raised-button
|
||||
type="button"
|
||||
class="source-card__cta"
|
||||
(click)="onAddPlaylist(card.type)"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ card.addLabelKey | translate }}
|
||||
</button>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('no-results') {
|
||||
<div class="empty-state no-results-state">
|
||||
<div class="illustration-container small">
|
||||
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
|
||||
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
|
||||
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
|
||||
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
|
||||
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('no-data') {
|
||||
<div class="empty-state no-data-state">
|
||||
<div class="empty-icon-container">
|
||||
<mat-icon class="empty-icon">{{ icon() }}</mat-icon>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@if (titleKey()) {
|
||||
<h3 class="title small">{{ titleKey() | translate }}</h3>
|
||||
}
|
||||
@if (descriptionKey()) {
|
||||
<p class="description">{{ descriptionKey() | translate }}</p>
|
||||
}
|
||||
|
||||
@if (primaryActionLabelKey() || secondaryActionLabelKey()) {
|
||||
<div class="actions">
|
||||
@if (primaryActionLabelKey(); as primaryLabel) {
|
||||
<button
|
||||
mat-raised-button
|
||||
type="button"
|
||||
class="primary-button"
|
||||
(click)="onPrimaryAction()"
|
||||
>
|
||||
<mat-icon>add_circle</mat-icon>
|
||||
{{ primaryLabel | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (secondaryActionLabelKey(); as secondaryLabel) {
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
class="secondary-button"
|
||||
(click)="onSecondaryAction()"
|
||||
>
|
||||
{{ secondaryLabel | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
|
||||
.content {
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.8s ease-out;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 500;
|
||||
margin: 0 0 12px 0;
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
|
||||
&.small {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 32px 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results-state {
|
||||
padding: 40px 24px;
|
||||
|
||||
.illustration-container {
|
||||
margin-bottom: 24px;
|
||||
opacity: 0.5;
|
||||
|
||||
&.small {
|
||||
animation: gentleFloat 1s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
|
||||
.search-circle,
|
||||
.search-handle {
|
||||
stroke: currentColor;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.empty-box,
|
||||
.empty-line {
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data-state {
|
||||
padding: 56px 24px;
|
||||
|
||||
.empty-icon-container {
|
||||
margin-bottom: 20px;
|
||||
animation: gentleFloat 0.6s ease-out;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
color: #60a5fa;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 10px 24px;
|
||||
border-radius: 10px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.25);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
color: #60a5fa;
|
||||
font-weight: 500;
|
||||
padding: 10px 18px;
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentleFloat {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 0.85;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(40px, 40px) rotate(0.5deg);
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { Component, inject, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util';
|
||||
import { WORKSPACE_PRODUCT_BRANDING } from 'services';
|
||||
|
||||
export type EmptyStateType =
|
||||
| 'welcome-dashboard'
|
||||
| 'welcome-sources'
|
||||
| 'no-results'
|
||||
| 'no-data';
|
||||
|
||||
interface FeatureCard {
|
||||
icon: string;
|
||||
titleKey: string;
|
||||
descKey: string;
|
||||
electronOnly?: boolean;
|
||||
}
|
||||
|
||||
interface SourceCard {
|
||||
type: WorkspacePlaylistType;
|
||||
icon: string;
|
||||
nameKey: string;
|
||||
needsKey: string;
|
||||
addLabelKey: string;
|
||||
contentKeys: readonly string[];
|
||||
}
|
||||
|
||||
const FEATURE_CARDS: readonly FeatureCard[] = [
|
||||
{
|
||||
icon: 'live_tv',
|
||||
titleKey: 'HOME.PLAYLISTS.FEATURE_LIVE_TITLE',
|
||||
descKey: 'HOME.PLAYLISTS.FEATURE_LIVE_DESC',
|
||||
},
|
||||
{
|
||||
icon: 'movie',
|
||||
titleKey: 'HOME.PLAYLISTS.FEATURE_VOD_TITLE',
|
||||
descKey: 'HOME.PLAYLISTS.FEATURE_VOD_DESC',
|
||||
},
|
||||
{
|
||||
icon: 'event_note',
|
||||
titleKey: 'HOME.PLAYLISTS.FEATURE_EPG_TITLE',
|
||||
descKey: 'HOME.PLAYLISTS.FEATURE_EPG_DESC',
|
||||
},
|
||||
{
|
||||
icon: 'download_for_offline',
|
||||
titleKey: 'HOME.PLAYLISTS.FEATURE_OFFLINE_TITLE',
|
||||
descKey: 'HOME.PLAYLISTS.FEATURE_OFFLINE_DESC',
|
||||
electronOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const SOURCE_CARDS: readonly SourceCard[] = [
|
||||
{
|
||||
type: 'url',
|
||||
icon: 'folder_open',
|
||||
nameKey: 'HOME.PLAYLISTS.FEATURE_M3U',
|
||||
needsKey: 'HOME.PLAYLISTS.SOURCE_M3U_NEEDS',
|
||||
addLabelKey: 'HOME.PLAYLISTS.SOURCE_M3U_ADD',
|
||||
contentKeys: [
|
||||
'HOME.PLAYLISTS.CONTENT_LIVE',
|
||||
'HOME.PLAYLISTS.CONTENT_EPG',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'xtream',
|
||||
icon: 'cloud',
|
||||
nameKey: 'HOME.PLAYLISTS.FEATURE_XTREAM',
|
||||
needsKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_NEEDS',
|
||||
addLabelKey: 'HOME.PLAYLISTS.SOURCE_XTREAM_ADD',
|
||||
contentKeys: [
|
||||
'HOME.PLAYLISTS.CONTENT_LIVE',
|
||||
'HOME.PLAYLISTS.CONTENT_VOD',
|
||||
'HOME.PLAYLISTS.CONTENT_SERIES',
|
||||
'HOME.PLAYLISTS.CONTENT_EPG',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'stalker',
|
||||
icon: 'cast',
|
||||
nameKey: 'HOME.PLAYLISTS.FEATURE_STALKER',
|
||||
needsKey: 'HOME.PLAYLISTS.SOURCE_STALKER_NEEDS',
|
||||
addLabelKey: 'HOME.PLAYLISTS.SOURCE_STALKER_ADD',
|
||||
contentKeys: [
|
||||
'HOME.PLAYLISTS.CONTENT_LIVE',
|
||||
'HOME.PLAYLISTS.CONTENT_VOD',
|
||||
'HOME.PLAYLISTS.CONTENT_SERIES',
|
||||
'HOME.PLAYLISTS.CONTENT_EPG',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-empty-state',
|
||||
templateUrl: './empty-state.component.html',
|
||||
styleUrls: [
|
||||
'./empty-state.component.scss',
|
||||
'./empty-state.welcome-dashboard.scss',
|
||||
'./empty-state.welcome-sources.scss',
|
||||
'./empty-state.responsive.scss',
|
||||
'./empty-state.themes.scss',
|
||||
],
|
||||
imports: [MatButtonModule, MatIcon, TranslatePipe],
|
||||
})
|
||||
export class EmptyStateComponent {
|
||||
readonly productBranding = inject(WORKSPACE_PRODUCT_BRANDING, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
readonly type = input.required<EmptyStateType>();
|
||||
|
||||
readonly icon = input<string>('inbox');
|
||||
readonly titleKey = input<string>('');
|
||||
readonly descriptionKey = input<string>('');
|
||||
readonly primaryActionLabelKey = input<string | null>(null);
|
||||
readonly secondaryActionLabelKey = input<string | null>(null);
|
||||
|
||||
readonly showElectronOnlyValueProps = input<boolean>(false);
|
||||
|
||||
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
|
||||
readonly primaryActionClicked = output<void>();
|
||||
readonly secondaryActionClicked = output<void>();
|
||||
|
||||
readonly featureCards = FEATURE_CARDS;
|
||||
|
||||
visibleSourceCards(): readonly SourceCard[] {
|
||||
if (this.productBranding?.xtreamOnlyUi) {
|
||||
return SOURCE_CARDS.filter((c) => c.type === 'xtream');
|
||||
}
|
||||
return SOURCE_CARDS;
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType): void {
|
||||
const resolved =
|
||||
type ??
|
||||
(this.productBranding?.xtreamOnlyUi ? 'xtream' : undefined);
|
||||
this.addPlaylistClicked.emit(resolved);
|
||||
}
|
||||
|
||||
onPrimaryAction(): void {
|
||||
this.primaryActionClicked.emit();
|
||||
}
|
||||
|
||||
onSecondaryAction(): void {
|
||||
this.secondaryActionClicked.emit();
|
||||
}
|
||||
|
||||
isFeatureCardVisible(card: FeatureCard): boolean {
|
||||
return !card.electronOnly || this.showElectronOnlyValueProps();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
@media (max-width: 480px) {
|
||||
.no-results-state {
|
||||
padding: 30px 20px;
|
||||
|
||||
.illustration {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-data-state {
|
||||
padding: 40px 20px;
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.no-results-state {
|
||||
padding: 24px 20px;
|
||||
.illustration-container { margin-bottom: 16px; }
|
||||
}
|
||||
|
||||
.no-data-state {
|
||||
padding: 32px 24px;
|
||||
.empty-icon { font-size: 56px; width: 56px; height: 56px; }
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 550px) {
|
||||
.no-results-state {
|
||||
padding: 16px 20px;
|
||||
.illustration-container { margin-bottom: 12px; }
|
||||
.illustration { width: 80px; height: 80px; }
|
||||
.title { font-size: 1.125rem; }
|
||||
.description { font-size: 0.8125rem; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
@media (prefers-color-scheme: light) {
|
||||
.empty-state {
|
||||
.title {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.description {
|
||||
opacity: 0.8;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results-state {
|
||||
.illustration {
|
||||
opacity: 0.3;
|
||||
|
||||
.search-circle,
|
||||
.search-handle,
|
||||
.empty-box,
|
||||
.empty-line {
|
||||
stroke: #64748b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-data-state {
|
||||
.empty-icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
color: #2563eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
.welcome-dashboard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: clamp(16px, 3vh, 36px) clamp(12px, 3vw, 32px);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
gap: clamp(16px, 3vh, 32px);
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 39px,
|
||||
rgba(59, 130, 246, 0.03) 39px,
|
||||
rgba(59, 130, 246, 0.03) 41px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 39px,
|
||||
rgba(59, 130, 246, 0.03) 39px,
|
||||
rgba(59, 130, 246, 0.03) 41px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 79px,
|
||||
rgba(59, 130, 246, 0.015) 79px,
|
||||
rgba(59, 130, 246, 0.015) 81px
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 0%,
|
||||
rgba(0, 0, 0, 0.7) 40%,
|
||||
rgba(0, 0, 0, 0.3) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 0%,
|
||||
rgba(0, 0, 0, 0.7) 40%,
|
||||
rgba(0, 0, 0, 0.3) 70%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: gridMove 60s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.welcome-dashboard__hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
min-width: 0;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.welcome-dashboard__illustration-container {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.welcome-dashboard__illustration {
|
||||
width: clamp(96px, 16vh, 160px);
|
||||
height: clamp(96px, 16vh, 160px);
|
||||
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
|
||||
color: #60a5fa;
|
||||
display: block;
|
||||
|
||||
.icon-background {
|
||||
fill: #3b82f6;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.monitor-base,
|
||||
.monitor-stand {
|
||||
fill: #3b82f6;
|
||||
}
|
||||
|
||||
.screen-border {
|
||||
stroke: #3b82f6;
|
||||
}
|
||||
|
||||
.wifi-dot {
|
||||
fill: #60a5fa;
|
||||
}
|
||||
|
||||
.wifi-arc {
|
||||
stroke: #60a5fa;
|
||||
opacity: 0.7;
|
||||
|
||||
&.arc-1 { opacity: 0.9; }
|
||||
&.arc-2 { opacity: 0.7; }
|
||||
&.arc-3 { opacity: 0.5; }
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-dashboard__title {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.4rem, 3.4vh, 2.1rem);
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.welcome-dashboard__tagline {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: clamp(0.95rem, 1.9vh, 1.05rem);
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.welcome-dashboard__description {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: clamp(0.8125rem, 1.7vh, 0.9375rem);
|
||||
opacity: 0.65;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.welcome-dashboard__cta {
|
||||
margin-top: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: clamp(0.875rem, 1.8vh, 1rem);
|
||||
padding: 10px 28px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.01em;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-dashboard__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr));
|
||||
gap: clamp(10px, 1.6vw, 18px);
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 4px 4px;
|
||||
list-style: none;
|
||||
justify-items: stretch;
|
||||
align-items: stretch;
|
||||
animation: slideUp 0.7s ease-out 0.1s backwards;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
padding: clamp(12px, 2vh, 18px) clamp(10px, 1.5vw, 14px);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(96, 165, 250, 0.08),
|
||||
rgba(37, 99, 235, 0.04)
|
||||
);
|
||||
border: 1px solid rgba(96, 165, 250, 0.18);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(8px);
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: center;
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
cursor: default;
|
||||
min-width: 0;
|
||||
|
||||
&:nth-child(2) { transform: rotate(1.5deg); }
|
||||
&:nth-child(3) { transform: rotate(-1deg); }
|
||||
&:nth-child(4) { transform: rotate(2deg); }
|
||||
|
||||
&:hover {
|
||||
transform: rotate(0deg) translateY(-4px) scale(1.04);
|
||||
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(96, 165, 250, 0.16),
|
||||
rgba(37, 99, 235, 0.1)
|
||||
);
|
||||
|
||||
.feature-card__icon-wrap {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-card__icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-card__title {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.feature-card__desc {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.78125rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.welcome-dashboard {
|
||||
gap: 16px;
|
||||
|
||||
.welcome-dashboard__cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
max-width: 420px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.welcome-dashboard .welcome-dashboard__cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.welcome-dashboard {
|
||||
gap: 12px;
|
||||
|
||||
.welcome-dashboard__description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.welcome-dashboard__hero {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.welcome-dashboard {
|
||||
animation: none;
|
||||
|
||||
.welcome-dashboard__hero,
|
||||
.welcome-dashboard__cards {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.feature-card,
|
||||
.feature-card:nth-child(n) {
|
||||
transform: none;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-dashboard__cta:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.welcome-dashboard {
|
||||
&::before {
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 39px,
|
||||
rgba(37, 99, 235, 0.04) 39px,
|
||||
rgba(37, 99, 235, 0.04) 41px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 39px,
|
||||
rgba(37, 99, 235, 0.04) 39px,
|
||||
rgba(37, 99, 235, 0.04) 41px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 79px,
|
||||
rgba(37, 99, 235, 0.02) 79px,
|
||||
rgba(37, 99, 235, 0.02) 81px
|
||||
);
|
||||
}
|
||||
|
||||
.welcome-dashboard__title {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.welcome-dashboard__tagline {
|
||||
color: #1e3a8a;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.welcome-dashboard__description {
|
||||
color: #64748b;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.welcome-dashboard__illustration {
|
||||
filter: drop-shadow(0 10px 30px rgba(37, 99, 235, 0.15));
|
||||
color: #3b82f6;
|
||||
|
||||
.icon-background {
|
||||
fill: #2563eb;
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.monitor-base,
|
||||
.monitor-stand {
|
||||
fill: #2563eb;
|
||||
}
|
||||
|
||||
.screen-border {
|
||||
stroke: #2563eb;
|
||||
}
|
||||
|
||||
.wifi-dot {
|
||||
fill: #3b82f6;
|
||||
}
|
||||
|
||||
.wifi-arc {
|
||||
stroke: #3b82f6;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
|
||||
border-color: rgba(37, 99, 235, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(29, 78, 216, 0.06));
|
||||
border-color: rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card__desc {
|
||||
color: #475569;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
.welcome-sources {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: clamp(16px, 3vh, 36px) clamp(12px, 3vw, 32px);
|
||||
overflow: hidden;
|
||||
gap: clamp(16px, 3vh, 28px);
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
.welcome-sources__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
max-width: 540px;
|
||||
width: 100%;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.welcome-sources__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
animation: gentleFloat 0.6s ease-out;
|
||||
}
|
||||
|
||||
.welcome-sources__icon {
|
||||
font-size: 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
color: #60a5fa;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.welcome-sources__title {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 8px 0;
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.welcome-sources__body {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.welcome-sources__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: clamp(10px, 1.6vw, 18px);
|
||||
width: 100%;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
animation: slideUp 0.7s ease-out 0.1s backwards;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: clamp(14px, 2.2vh, 22px);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(96, 165, 250, 0.08),
|
||||
rgba(37, 99, 235, 0.04)
|
||||
);
|
||||
border: 1px solid rgba(96, 165, 250, 0.2);
|
||||
border-radius: 16px;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
min-width: 0;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 12px 28px rgba(59, 130, 246, 0.22);
|
||||
border-color: rgba(96, 165, 250, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.source-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-card__icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #60a5fa;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-card__name {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.source-card__needs {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.75;
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
min-height: 2.5em;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.source-card__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.source-card__chip {
|
||||
font-family: Roboto, 'Helvetica Neue', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(96, 165, 250, 0.14);
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(96, 165, 250, 0.22);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.source-card__cta {
|
||||
margin-top: auto;
|
||||
align-self: stretch;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
padding: 8px 18px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
text-transform: none;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.welcome-sources .welcome-sources__grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
.welcome-sources {
|
||||
gap: 14px;
|
||||
|
||||
.source-card__needs {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.welcome-sources {
|
||||
animation: none;
|
||||
|
||||
.welcome-sources__header,
|
||||
.welcome-sources__grid {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.source-card:hover,
|
||||
.source-card__cta:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.welcome-sources {
|
||||
.welcome-sources__icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.welcome-sources__title {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.welcome-sources__body {
|
||||
color: #64748b;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
background: linear-gradient(145deg, rgba(37, 99, 235, 0.06), rgba(29, 78, 216, 0.03));
|
||||
border-color: rgba(37, 99, 235, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(145deg, rgba(37, 99, 235, 0.1), rgba(29, 78, 216, 0.05));
|
||||
border-color: rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.source-card__icon {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.source-card__needs {
|
||||
color: #475569;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.source-card__chip {
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
<form
|
||||
[formGroup]="playlistDetails"
|
||||
(ngSubmit)="saveChanges(playlistDetails.value)"
|
||||
>
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
|
||||
</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="title" matInput />
|
||||
</mat-form-field>
|
||||
@if (playlist.portalUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="portalUrl" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.macAddress) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="macAddress" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSerialNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature2" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.serverUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="serverUrl" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.username) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="username" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.password) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="password" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.url) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="url" />
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="copyUrl()"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filename) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="filename" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="importDate" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.count) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="count" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop) {
|
||||
<mat-form-field class="w-full mb-10">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="userAgent" matInput />
|
||||
<mat-hint>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filePath) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
|
||||
</mat-label>
|
||||
<input formControlName="filePath" matInput />
|
||||
@if (playlist.updateState === 2) {
|
||||
<mat-hint>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
|
||||
| translate
|
||||
}}
|
||||
</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop && (playlist.url || playlist.filePath)) {
|
||||
<mat-checkbox class="w-full" formControlName="autoRefresh">
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
|
||||
</mat-checkbox>
|
||||
<p>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!playlist.serverUrl && !playlist.portalUrl) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
(click)="exportPlaylist()"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
<span class="spacer"></span>
|
||||
<button mat-button mat-dialog-close>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
mat-dialog-close
|
||||
color="accent"
|
||||
type="submit"
|
||||
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
|
||||
>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
FormControl,
|
||||
ReactiveFormsModule,
|
||||
UntypedFormBuilder,
|
||||
UntypedFormGroup,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import { Playlist, PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-info',
|
||||
templateUrl: './playlist-info.component.html',
|
||||
styles: [
|
||||
`
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
// Material's '.mat-mdc-dialog-title + .mat-mdc-dialog-content' rule
|
||||
// zeroes padding-top with higher specificity than a scoped override,
|
||||
// which clips the first field's floating label. Pushing the first
|
||||
// field down with margin-top side-steps that entirely.
|
||||
mat-dialog-content > mat-form-field:first-child {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
mat-dialog-content mat-checkbox {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
mat-dialog-content p {
|
||||
margin: 0;
|
||||
color: var(--app-muted-color);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
`,
|
||||
],
|
||||
providers: [DatePipe],
|
||||
imports: [
|
||||
ClipboardModule,
|
||||
MatButton,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
MatTooltip,
|
||||
ReactiveFormsModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PlaylistInfoComponent {
|
||||
private clipboard = inject(Clipboard);
|
||||
private datePipe = inject(DatePipe);
|
||||
private formBuilder = inject(UntypedFormBuilder);
|
||||
private playlistsService = inject(PlaylistsService);
|
||||
private store = inject(Store);
|
||||
private databaseService = inject(DatabaseService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
private translate = inject(TranslateService);
|
||||
public playlistData = inject<Playlist & { id: string }>(MAT_DIALOG_DATA);
|
||||
|
||||
readonly isDesktop = !!window.electron;
|
||||
|
||||
/** Playlist object */
|
||||
playlist: Playlist & { id: string };
|
||||
|
||||
/** Form group with playlist details */
|
||||
playlistDetails!: UntypedFormGroup;
|
||||
|
||||
constructor() {
|
||||
this.playlist = this.playlistData;
|
||||
this.createForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the form and set initial data on component init
|
||||
*/
|
||||
createForm(): void {
|
||||
this.playlistDetails = this.formBuilder.group({
|
||||
_id: this.playlist._id,
|
||||
title: new FormControl(this.playlist.title, Validators.required),
|
||||
userAgent: this.playlist.userAgent || '',
|
||||
filename: new FormControl({
|
||||
value: this.playlist.filename || '',
|
||||
disabled: true,
|
||||
}),
|
||||
count: new FormControl({
|
||||
value: this.playlist.count,
|
||||
disabled: true,
|
||||
}),
|
||||
importDate: new FormControl({
|
||||
value: this.datePipe.transform(this.playlist.importDate),
|
||||
disabled: true,
|
||||
}),
|
||||
url: new FormControl({
|
||||
value: this.playlist.url,
|
||||
disabled: true,
|
||||
}),
|
||||
filePath: new FormControl({
|
||||
value: this.playlist.filePath,
|
||||
disabled: true,
|
||||
}),
|
||||
autoRefresh: new FormControl(this.playlist.autoRefresh),
|
||||
serverUrl: new FormControl(this.playlist.serverUrl),
|
||||
username: new FormControl(this.playlist.username),
|
||||
password: new FormControl(this.playlist.password),
|
||||
macAddress: new FormControl(this.playlist.macAddress),
|
||||
portalUrl: new FormControl(this.playlist.portalUrl),
|
||||
stalkerSerialNumber: new FormControl(
|
||||
this.playlist.stalkerSerialNumber
|
||||
),
|
||||
stalkerDeviceId1: new FormControl(this.playlist.stalkerDeviceId1),
|
||||
stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2),
|
||||
stalkerSignature1: new FormControl(this.playlist.stalkerSignature1),
|
||||
stalkerSignature2: new FormControl(this.playlist.stalkerSignature2),
|
||||
});
|
||||
}
|
||||
|
||||
async saveChanges(playlist: PlaylistMeta): Promise<void> {
|
||||
try {
|
||||
const isXtream =
|
||||
this.playlist &&
|
||||
this.playlist.username &&
|
||||
this.playlist.password &&
|
||||
this.playlist.serverUrl;
|
||||
|
||||
if (isXtream) {
|
||||
await this.updateXtreamPlaylist(playlist);
|
||||
}
|
||||
|
||||
// Dispatch store action to update UI
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({ playlist })
|
||||
);
|
||||
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
|
||||
),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 3000 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_FAILED'),
|
||||
this.translate.instant('CLOSE'),
|
||||
{
|
||||
duration: 3000,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateXtreamPlaylist(playlist: PlaylistMeta) {
|
||||
const success = await this.databaseService.updateXtreamPlaylistDetails({
|
||||
id: this.playlist._id,
|
||||
title: playlist.title,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
serverUrl: playlist.serverUrl,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Failed to update playlist in database');
|
||||
}
|
||||
|
||||
// TODO: circular dependency
|
||||
/* this.xtreamStore.updatePlaylist({
|
||||
name: playlist.title,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
serverUrl: playlist.serverUrl,
|
||||
}); */
|
||||
}
|
||||
|
||||
async exportPlaylist() {
|
||||
const playlistAsString = await firstValueFrom(
|
||||
this.playlistsService.getRawPlaylistById(this.playlist._id)
|
||||
);
|
||||
|
||||
if (this.isDesktop) {
|
||||
try {
|
||||
const savePath = await window.electron.saveFileDialog(
|
||||
`${this.playlist.title || 'exported'}.m3u8`,
|
||||
[
|
||||
{
|
||||
name: 'Playlist',
|
||||
extensions: ['m3u8', 'm3u'],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
if (savePath) {
|
||||
await window.electron.writeFile(savePath, playlistAsString);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_EXPORT_SUCCESS'
|
||||
),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to export playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST_FAILED'
|
||||
),
|
||||
this.translate.instant('CLOSE'),
|
||||
{
|
||||
duration: 3000,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const element = document.createElement('a');
|
||||
element.setAttribute(
|
||||
'href',
|
||||
'data:text/plain;charset=utf-8,' +
|
||||
encodeURIComponent(playlistAsString)
|
||||
);
|
||||
element.setAttribute(
|
||||
'download',
|
||||
this.playlist.title || 'exported.m3u'
|
||||
);
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy URL to clipboard
|
||||
*/
|
||||
copyUrl(): void {
|
||||
const url = this.playlistDetails.get('url')?.value;
|
||||
if (url) {
|
||||
const success = this.clipboard.copy(url);
|
||||
if (success) {
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'
|
||||
),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 2000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
<div
|
||||
class="playlist-item"
|
||||
cdkDrag
|
||||
[cdkDragDisabled]="!isDraggable() || isBusy()"
|
||||
cdkDragPreviewContainer="parent"
|
||||
[class.selected]="isSelected()"
|
||||
[class.is-busy]="isBusy()"
|
||||
(click)="onPlaylistClick()"
|
||||
>
|
||||
@if (isDraggable()) {
|
||||
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
|
||||
}
|
||||
<div class="playlist-icon">
|
||||
@if (item.url) {
|
||||
<div class="icon-container">
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="
|
||||
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) +
|
||||
' ' +
|
||||
item.url
|
||||
"
|
||||
>cloud</mat-icon
|
||||
>
|
||||
@if (item.autoRefresh) {
|
||||
<mat-icon
|
||||
class="auto-refresh-indicator"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
|
||||
"
|
||||
>autorenew</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!item.url && !item.serverUrl && !item.macAddress) {
|
||||
<div class="icon-container">
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
|
||||
>folder</mat-icon
|
||||
>
|
||||
@if (item.autoRefresh) {
|
||||
<mat-icon
|
||||
class="auto-refresh-indicator"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate
|
||||
"
|
||||
>autorenew</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (item.serverUrl) {
|
||||
<div class="icon-container">
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
|
||||
>public</mat-icon
|
||||
>
|
||||
<span
|
||||
class="status-dot"
|
||||
[class]="getStatusClass()"
|
||||
[matTooltip]="'Portal status: ' + portalStatus"
|
||||
[attr.aria-label]="'Portal status: ' + portalStatus"
|
||||
></span>
|
||||
</div>
|
||||
}
|
||||
@if (item.macAddress) {
|
||||
<mat-icon
|
||||
class="upload-type-icon"
|
||||
[matTooltip]="'HOME.PLAYLISTS.STALKER_PORTAL' | translate"
|
||||
>dashboard</mat-icon
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="playlist-content">
|
||||
<div class="playlist-title">
|
||||
{{ item.title || item.filename }}
|
||||
</div>
|
||||
<div class="meta">
|
||||
@if (!item.serverUrl && !item.portalUrl) {
|
||||
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
|
||||
@if (item.importDate.length > 3) {
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{ item.importDate | date: undefined : undefined : currentLocale() }}
|
||||
}
|
||||
@if (item.updateDate) {
|
||||
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
|
||||
{{
|
||||
item.updateDate
|
||||
| date
|
||||
: 'MMMM d, yyyy, HH:mm'
|
||||
: undefined
|
||||
: currentLocale()
|
||||
}}
|
||||
}
|
||||
@if (item.updateState === 2) {
|
||||
<mat-icon
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
|
||||
| translate
|
||||
"
|
||||
[inline]="true"
|
||||
>warning</mat-icon
|
||||
>
|
||||
}
|
||||
} @else {
|
||||
@if (item.macAddress) {
|
||||
Stalker
|
||||
} @else {
|
||||
Xtream
|
||||
}
|
||||
@if (item.importDate.length > 3) {
|
||||
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
|
||||
{{ item.importDate | date: undefined : undefined : currentLocale() }}
|
||||
}
|
||||
@if (item.updateDate) {
|
||||
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
|
||||
{{
|
||||
item.updateDate
|
||||
| date
|
||||
: 'MMMM d, yyyy, HH:mm'
|
||||
: undefined
|
||||
: currentLocale()
|
||||
}}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (isBusy() && busyMessage()) {
|
||||
<div class="busy-state">
|
||||
<span class="busy-state__message">{{ busyMessage() }}</span>
|
||||
@if (busyProgress() !== null) {
|
||||
<span class="busy-state__value">{{ busyProgress() }}%</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (isBusy() && busyMessage() && busyProgress() !== null) {
|
||||
<div class="busy-state__bar">
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="busyProgress() ?? 0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (isBusy() && busyMessage() && busyProgress() === null) {
|
||||
<div class="busy-state__bar">
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showActions()) {
|
||||
<div class="action-buttons">
|
||||
@if (canCancelBusyAction()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
class="cancel-btn"
|
||||
(click)="
|
||||
$event.stopPropagation(); cancelBusyActionClicked.emit()
|
||||
"
|
||||
[matTooltip]="
|
||||
'WORKSPACE.SHELL.XTREAM_IMPORT_CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (item.url || item.filePath) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
(click)="
|
||||
$event.stopPropagation(); refreshClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
|
||||
>
|
||||
@if (isRefreshing()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (item.serverUrl && isElectron) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="refresh-btn"
|
||||
(click)="
|
||||
$event.stopPropagation(); refreshClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
|
||||
>
|
||||
@if (isRefreshing()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>sync</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="edit-btn"
|
||||
(click)="
|
||||
$event.stopPropagation(); editPlaylistClicked.emit(item)
|
||||
"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
class="delete-btn"
|
||||
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
|
||||
[disabled]="isBusy()"
|
||||
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
|
||||
>
|
||||
@if (isDeleting()) {
|
||||
<mat-spinner
|
||||
class="action-spinner"
|
||||
diameter="18"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>delete</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--app-selection-surface-strong),
|
||||
var(--app-selection-surface)
|
||||
);
|
||||
border-color: var(--app-selection-border);
|
||||
color: var(--app-selection-color);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--app-selection-border),
|
||||
0 12px 20px -18px var(--app-selection-glow);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.meta,
|
||||
.upload-type-icon,
|
||||
.drag-icon,
|
||||
.action-buttons button,
|
||||
.action-buttons mat-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-surface-strong) 85%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-busy {
|
||||
cursor: progress;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.playlist-content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.96rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.82rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.84;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.busy-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--app-selection-border, #8aa4c8);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.busy-state__message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.busy-state__value {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.busy-state__bar {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.cdk-drag-preview {
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.25),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15),
|
||||
0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cdk-drag-animating {
|
||||
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
flex-shrink: 0;
|
||||
cursor: move;
|
||||
opacity: 0.46;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-inline-start: auto;
|
||||
|
||||
button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
color: inherit;
|
||||
opacity: 0.76;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
transform: none;
|
||||
opacity: 0.38;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-spinner {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
.upload-type-icon {
|
||||
opacity: 0.75;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.playlist-item:hover & .upload-type-icon {
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border: 2px solid var(--mat-sys-surface);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #4caf50;
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background-color: #f44336;
|
||||
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
|
||||
}
|
||||
|
||||
.status-expired {
|
||||
background-color: #ff9800;
|
||||
box-shadow: 0 0 8px rgba(255, 152, 0, 0.4);
|
||||
}
|
||||
|
||||
.status-unavailable {
|
||||
background-color: var(--mat-sys-on-surface-variant);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
font-size: 14px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
color: #4caf50;
|
||||
background-color: var(--mat-sys-surface);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.15s ease;
|
||||
|
||||
.playlist-item:hover & {
|
||||
color: #66bb6a;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.upload-type-icon,
|
||||
.icon-container,
|
||||
.action-buttons .refresh-btn,
|
||||
.action-buttons .edit-btn,
|
||||
.action-buttons .delete-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { MockModule } from 'ng-mocks';
|
||||
import { PortalStatusService } from 'services';
|
||||
import { PlaylistItemComponent } from './playlist-item.component';
|
||||
|
||||
describe('PlaylistItemComponent', () => {
|
||||
let component: PlaylistItemComponent;
|
||||
let fixture: ComponentFixture<PlaylistItemComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
PlaylistItemComponent,
|
||||
MockModule(MatIconModule),
|
||||
MockModule(MatListModule),
|
||||
MockModule(MatTooltipModule),
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PortalStatusService,
|
||||
useValue: {
|
||||
checkPortalStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue('active'),
|
||||
getStatusClass: jest.fn(() => 'status-active'),
|
||||
getStatusIcon: jest.fn(() => 'check_circle'),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(PlaylistItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.item = {
|
||||
title: 'Playlist',
|
||||
_id: '1',
|
||||
count: 10,
|
||||
importDate: Date.now().toString(),
|
||||
autoRefresh: false,
|
||||
};
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows busy UI and suppresses playlist clicks while deleting', () => {
|
||||
const emitSpy = jest.spyOn(component.playlistClicked, 'emit');
|
||||
|
||||
fixture.componentRef.setInput('isDeleting', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
const deleteButton = nativeElement.querySelector(
|
||||
'.delete-btn'
|
||||
) as HTMLButtonElement;
|
||||
const editButton = nativeElement.querySelector(
|
||||
'.edit-btn'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(component.isBusy()).toBe(true);
|
||||
expect(deleteButton.disabled).toBe(true);
|
||||
expect(editButton.disabled).toBe(true);
|
||||
expect(nativeElement.querySelector('.action-spinner')).not.toBeNull();
|
||||
|
||||
component.onPlaylistClick();
|
||||
expect(emitSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders cancel and progress UI for long-running playlist actions', () => {
|
||||
fixture.componentRef.setInput('isDeleting', true);
|
||||
fixture.componentRef.setInput('busyMessage', 'Removing cached content...');
|
||||
fixture.componentRef.setInput('busyProgress', 42);
|
||||
fixture.componentRef.setInput('canCancelBusyAction', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
expect(nativeElement.querySelector('.busy-state__message')?.textContent).toContain(
|
||||
'Removing cached content...'
|
||||
);
|
||||
expect(nativeElement.querySelector('.busy-state__value')?.textContent).toContain(
|
||||
'42%'
|
||||
);
|
||||
expect(nativeElement.querySelector('.cancel-btn')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { PortalStatus, PortalStatusService } from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-item',
|
||||
templateUrl: './playlist-item.component.html',
|
||||
styleUrls: ['./playlist-item.component.scss'],
|
||||
imports: [
|
||||
DatePipe,
|
||||
DragDropModule,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatProgressBarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PlaylistItemComponent implements OnInit {
|
||||
@Input() item!: PlaylistMeta;
|
||||
readonly showActions = input(true);
|
||||
readonly isDraggable = input(false);
|
||||
readonly isSelected = input(false);
|
||||
readonly isRefreshing = input(false);
|
||||
readonly isDeleting = input(false);
|
||||
readonly busyMessage = input('');
|
||||
readonly busyProgress = input<number | null>(null);
|
||||
readonly canCancelBusyAction = input(false);
|
||||
readonly isBusy = computed(() => this.isRefreshing() || this.isDeleting());
|
||||
|
||||
readonly editPlaylistClicked = output<PlaylistMeta>();
|
||||
readonly playlistClicked = output<string>();
|
||||
readonly refreshClicked = output<PlaylistMeta>();
|
||||
readonly removeClicked = output<string>();
|
||||
readonly cancelBusyActionClicked = output<void>();
|
||||
|
||||
portalStatus: PortalStatus = 'unavailable';
|
||||
private readonly portalStatusService = inject(PortalStatusService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly isElectron = !!window.electron;
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
async ngOnInit() {
|
||||
await this.checkPortalStatus();
|
||||
}
|
||||
|
||||
private async checkPortalStatus() {
|
||||
if (this.item.serverUrl && this.item.username && this.item.password) {
|
||||
this.portalStatus = await this.portalStatusService.checkPortalStatus(
|
||||
this.item.serverUrl,
|
||||
this.item.username,
|
||||
this.item.password
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getStatusClass(): string {
|
||||
return this.portalStatusService.getStatusClass(this.portalStatus);
|
||||
}
|
||||
|
||||
getStatusIcon(): string {
|
||||
return this.portalStatusService.getStatusIcon(this.portalStatus);
|
||||
}
|
||||
|
||||
onPlaylistClick(): void {
|
||||
if (this.isBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.playlistClicked.emit(this.item._id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@if (playlistsData(); as data) {
|
||||
@if (data.playlists.length === 0) {
|
||||
@if (data.totalCount === 0) {
|
||||
<!-- Welcome state: no playlists at all -->
|
||||
<app-empty-state
|
||||
[type]="'welcome-sources'"
|
||||
[showElectronOnlyValueProps]="isElectron"
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
} @else {
|
||||
<!-- No results state: search/filter returned nothing -->
|
||||
<app-empty-state [type]="'no-results'" />
|
||||
}
|
||||
} @else {
|
||||
<div
|
||||
class="playlists-list"
|
||||
cdkDropList
|
||||
[cdkDropListDisabled]="!isCustomSortActive()"
|
||||
(cdkDropListDropped)="drop($event, data.playlists)"
|
||||
>
|
||||
@for (
|
||||
item of data.playlists;
|
||||
track item._id;
|
||||
let last = $last
|
||||
) {
|
||||
<app-playlist-item
|
||||
[item]="item"
|
||||
[isSelected]="activePlaylistId() === item._id"
|
||||
[showActions]="!sidebarMode()"
|
||||
[isDraggable]="isCustomSortActive()"
|
||||
[isRefreshing]="isRefreshPending(item._id)"
|
||||
[isDeleting]="isDeletePending(item._id)"
|
||||
[busyMessage]="getBusyMessage(item)"
|
||||
[busyProgress]="getBusyProgress(item._id)"
|
||||
[canCancelBusyAction]="canCancelBusyOperation(item)"
|
||||
(editPlaylistClicked)="openInfoDialog($event)"
|
||||
(playlistClicked)="getPlaylist(item)"
|
||||
(refreshClicked)="refreshPlaylist($event)"
|
||||
(removeClicked)="removeClicked(item)"
|
||||
(cancelBusyActionClicked)="cancelBusyOperation(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:host app-empty-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.playlists-list {
|
||||
padding: 0 6px;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#skeleton-container {
|
||||
padding-top: 10px;
|
||||
width: 100%;
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 0 20px;
|
||||
height: 72px;
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
align-self: center;
|
||||
padding-left: 6px;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 480px) {
|
||||
.favorites-icon {
|
||||
padding-left: 0 !important;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { MockStore, provideMockStore } from '@ngrx/store/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
DatabaseService,
|
||||
DataService,
|
||||
PlaybackPositionService,
|
||||
SortBy,
|
||||
SortOrder,
|
||||
SortService,
|
||||
} from 'services';
|
||||
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
|
||||
import { RecentPlaylistsComponent } from './recent-playlists.component';
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createAbortError(): Error {
|
||||
const error = new Error('Cancelled');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function createPlaylistMeta(
|
||||
overrides: Partial<PlaylistMeta> = {}
|
||||
): PlaylistMeta {
|
||||
return {
|
||||
_id: 'playlist-1',
|
||||
title: 'Xtream Playlist',
|
||||
count: 0,
|
||||
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
|
||||
autoRefresh: false,
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
...overrides,
|
||||
} as PlaylistMeta;
|
||||
}
|
||||
|
||||
describe('RecentPlaylistsComponent busy state', () => {
|
||||
let component: RecentPlaylistsComponent;
|
||||
let databaseService: {
|
||||
cancelOperation: jest.Mock;
|
||||
createOperationId: jest.Mock;
|
||||
deletePlaylist: jest.Mock;
|
||||
deleteXtreamPlaylistContent: jest.Mock;
|
||||
updateXtreamPlaylistDetails: jest.Mock;
|
||||
};
|
||||
let dialogService: {
|
||||
openConfirmDialog: jest.Mock;
|
||||
};
|
||||
let dataService: {
|
||||
sendIpcEvent: jest.Mock;
|
||||
};
|
||||
let playbackPositionService: {
|
||||
getAllPlaybackPositions: jest.Mock;
|
||||
};
|
||||
let router: {
|
||||
navigate: jest.Mock;
|
||||
};
|
||||
let snackBar: {
|
||||
open: jest.Mock;
|
||||
};
|
||||
let store: MockStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
databaseService = {
|
||||
cancelOperation: jest.fn().mockResolvedValue(true),
|
||||
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
|
||||
deletePlaylist: jest.fn(),
|
||||
deleteXtreamPlaylistContent: jest.fn(),
|
||||
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
dialogService = {
|
||||
openConfirmDialog: jest.fn(),
|
||||
};
|
||||
dataService = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
playbackPositionService = {
|
||||
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
router = {
|
||||
navigate: jest.fn(),
|
||||
};
|
||||
snackBar = {
|
||||
open: jest.fn(),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecentPlaylistsComponent],
|
||||
providers: [
|
||||
provideMockStore({
|
||||
selectors: [
|
||||
{ selector: selectPlaylistsLoadingFlag, value: true },
|
||||
{
|
||||
selector: selectAllPlaylistsMeta,
|
||||
value: [],
|
||||
},
|
||||
{
|
||||
selector: selectActiveTypeFilters,
|
||||
value: ['xtream', 'm3u', 'stalker'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
provide: DatabaseService,
|
||||
useValue: databaseService,
|
||||
},
|
||||
{
|
||||
provide: DialogService,
|
||||
useValue: dialogService,
|
||||
},
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: dataService,
|
||||
},
|
||||
{
|
||||
provide: PlaybackPositionService,
|
||||
useValue: playbackPositionService,
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: {
|
||||
resolvedPlaylistId: signal<string | null>(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MatSnackBar,
|
||||
useValue: snackBar,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: router,
|
||||
},
|
||||
{
|
||||
provide: SortService,
|
||||
useValue: {
|
||||
getSortOptions: jest.fn(() =>
|
||||
of({
|
||||
by: SortBy.DATE_ADDED,
|
||||
order: SortOrder.DESC,
|
||||
})
|
||||
),
|
||||
sortPlaylists: jest.fn(
|
||||
(playlists: PlaylistMeta[]) => playlists
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
component = TestBed.createComponent(
|
||||
RecentPlaylistsComponent
|
||||
).componentInstance;
|
||||
store = TestBed.inject(MockStore);
|
||||
jest.spyOn(store, 'dispatch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('tracks delete progress and clears the busy row after completion', async () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
|
||||
const deletion = createDeferred<boolean>();
|
||||
|
||||
databaseService.deletePlaylist.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
options?: {
|
||||
onEvent?: (event: any) => void;
|
||||
operationId?: string;
|
||||
}
|
||||
) => {
|
||||
options?.onEvent?.({
|
||||
operation: 'delete-playlist',
|
||||
operationId: 'playlist-delete-op',
|
||||
status: 'progress',
|
||||
phase: 'deleting-content',
|
||||
current: 25,
|
||||
total: 100,
|
||||
});
|
||||
return deletion.promise;
|
||||
}
|
||||
);
|
||||
|
||||
const removalPromise = component.removePlaylist(item);
|
||||
|
||||
expect(component.isDeletePending(item._id)).toBe(true);
|
||||
expect(component.getBusyMessage(item)).toBe(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
expect(component.getBusyProgress(item._id)).toBe(25);
|
||||
expect(component.canCancelBusyOperation(item)).toBe(true);
|
||||
|
||||
await component.cancelBusyOperation(item);
|
||||
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
|
||||
'playlist-delete-op'
|
||||
);
|
||||
|
||||
deletion.resolve(true);
|
||||
await removalPromise;
|
||||
|
||||
expect(component.isDeletePending(item._id)).toBe(false);
|
||||
expect(component.getBusyProgress(item._id)).toBeNull();
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.removePlaylist({ playlistId: item._id })
|
||||
);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-refresh-1' });
|
||||
const refresh = createDeferred<{
|
||||
success: boolean;
|
||||
favorites: Array<{ xtreamId: number; contentType: string }>;
|
||||
recentlyViewed: Array<{
|
||||
xtreamId: number;
|
||||
contentType: string;
|
||||
viewedAt: string;
|
||||
}>;
|
||||
hiddenCategories: Array<{
|
||||
xtreamId: number;
|
||||
categoryType: string;
|
||||
}>;
|
||||
}>();
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
|
||||
databaseService.deleteXtreamPlaylistContent.mockImplementation(
|
||||
(
|
||||
_playlistId: string,
|
||||
options?: {
|
||||
onEvent?: (event: any) => void;
|
||||
operationId?: string;
|
||||
}
|
||||
) => {
|
||||
options?.onEvent?.({
|
||||
operation: 'delete-xtream-content',
|
||||
operationId: 'xtream-refresh-op',
|
||||
status: 'progress',
|
||||
phase: 'collecting-user-data',
|
||||
current: 1,
|
||||
total: 4,
|
||||
});
|
||||
return refresh.promise;
|
||||
}
|
||||
);
|
||||
|
||||
component.refreshXtreamPlaylist(item);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(component.isRefreshPending(item._id)).toBe(true);
|
||||
expect(component.getBusyMessage(item)).toBe(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
|
||||
);
|
||||
expect(component.getBusyProgress(item._id)).toBe(25);
|
||||
expect(component.canCancelBusyOperation(item)).toBe(true);
|
||||
|
||||
await component.cancelBusyOperation(item);
|
||||
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
|
||||
'xtream-refresh-op'
|
||||
);
|
||||
|
||||
refresh.reject(createAbortError());
|
||||
await confirmPromise;
|
||||
|
||||
expect(component.isRefreshPending(item._id)).toBe(false);
|
||||
expect(component.getBusyProgress(item._id)).toBeNull();
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const executionOrder: string[] = [];
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(1712145600000);
|
||||
const originalSetItem = Storage.prototype.setItem;
|
||||
const setItemSpy = jest
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(function (key: string, value: string) {
|
||||
executionOrder.push('setItem');
|
||||
return originalSetItem.call(this, key, value);
|
||||
});
|
||||
|
||||
dialogService.openConfirmDialog.mockImplementation(
|
||||
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
|
||||
confirmPromise = onConfirm?.();
|
||||
}
|
||||
);
|
||||
(store.dispatch as jest.Mock).mockImplementation((action: unknown) => {
|
||||
executionOrder.push('dispatch');
|
||||
return action;
|
||||
});
|
||||
router.navigate.mockImplementation((commands: unknown[]) => {
|
||||
executionOrder.push('navigate');
|
||||
return Promise.resolve(Boolean(commands));
|
||||
});
|
||||
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
|
||||
success: true,
|
||||
favorites: [
|
||||
{ xtreamId: 101, contentType: 'live' },
|
||||
{ xtreamId: 202, contentType: 'movie' },
|
||||
],
|
||||
recentlyViewed: [
|
||||
{
|
||||
xtreamId: 303,
|
||||
contentType: 'series',
|
||||
viewedAt: '2026-04-03T11:15:00.000Z',
|
||||
},
|
||||
],
|
||||
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
|
||||
});
|
||||
|
||||
component.refreshXtreamPlaylist(item);
|
||||
await confirmPromise;
|
||||
|
||||
expect(
|
||||
databaseService.updateXtreamPlaylistDetails
|
||||
).toHaveBeenCalledWith({
|
||||
id: item._id,
|
||||
updateDate: 1712145600000,
|
||||
});
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate: 1712145600000 },
|
||||
})
|
||||
);
|
||||
expect(setItemSpy).toHaveBeenCalledWith(
|
||||
`xtream-restore-${item._id}`,
|
||||
JSON.stringify({
|
||||
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
|
||||
favorites: [
|
||||
{ xtreamId: 101, contentType: 'live' },
|
||||
{ xtreamId: 202, contentType: 'movie' },
|
||||
],
|
||||
recentlyViewed: [
|
||||
{
|
||||
xtreamId: 303,
|
||||
contentType: 'series',
|
||||
viewedAt: '2026-04-03T11:15:00.000Z',
|
||||
},
|
||||
],
|
||||
playbackPositions: [],
|
||||
})
|
||||
);
|
||||
expect(router.navigate).toHaveBeenCalledWith([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'playlist-m3u-1',
|
||||
serverUrl: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
filePath: '/tmp/test.m3u',
|
||||
});
|
||||
|
||||
component.refreshPlaylist(item);
|
||||
|
||||
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
filePath: item.filePath,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,739 @@
|
||||
import {
|
||||
CdkDragDrop,
|
||||
DragDropModule,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import type { WorkspacePlaylistType } from '@iptvnator/workspace/shell/util';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||
import { DialogService } from 'components';
|
||||
import {
|
||||
DatabaseService,
|
||||
DataService,
|
||||
DbOperationEvent,
|
||||
isDbAbortError,
|
||||
PlaybackPositionService,
|
||||
PlaylistRefreshService,
|
||||
SortBy,
|
||||
SortService,
|
||||
XtreamPendingRestoreService,
|
||||
} from 'services';
|
||||
import {
|
||||
PLAYLIST_UPDATE,
|
||||
PlaylistMeta,
|
||||
PlaylistRefreshEvent,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
|
||||
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
|
||||
|
||||
type PlaylistBusyOperation = {
|
||||
current?: number;
|
||||
operation: string;
|
||||
operationId?: string;
|
||||
phase?: string;
|
||||
status: DbOperationEvent['status'];
|
||||
total?: number;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-recent-playlists',
|
||||
templateUrl: './recent-playlists.component.html',
|
||||
styleUrls: ['./recent-playlists.component.scss'],
|
||||
imports: [
|
||||
DragDropModule,
|
||||
EmptyStateComponent,
|
||||
MatInputModule,
|
||||
PlaylistItemComponent,
|
||||
],
|
||||
})
|
||||
export class RecentPlaylistsComponent {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly dialogService = inject(DialogService);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly playbackPositionService = inject(PlaybackPositionService);
|
||||
private readonly playlistRefreshService = inject(PlaylistRefreshService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly sortService = inject(SortService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly pendingRestoreService = inject(
|
||||
XtreamPendingRestoreService
|
||||
);
|
||||
|
||||
readonly sidebarMode = input(false);
|
||||
readonly searchQueryInput = input<string>('');
|
||||
readonly playlistClicked = output<string>();
|
||||
readonly addPlaylistClicked = output<WorkspacePlaylistType | undefined>();
|
||||
|
||||
readonly isElectron = !!window.electron;
|
||||
|
||||
readonly allPlaylistsLoaded = this.store.selectSignal(
|
||||
selectPlaylistsLoadingFlag
|
||||
);
|
||||
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
|
||||
|
||||
private readonly currentSortOptions = toSignal(
|
||||
this.sortService.getSortOptions(),
|
||||
{ requireSync: true }
|
||||
);
|
||||
|
||||
readonly isCustomSortActive = () =>
|
||||
this.currentSortOptions().by === SortBy.CUSTOM;
|
||||
|
||||
readonly searchQuery = new BehaviorSubject<string>('');
|
||||
readonly pendingDeletionIds = signal<Set<string>>(new Set());
|
||||
readonly pendingRefreshIds = signal<Set<string>>(new Set());
|
||||
readonly busyOperations = signal<Map<string, PlaylistBusyOperation>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// Update searchQuery when input changes
|
||||
effect(() => {
|
||||
this.searchQuery.next(this.searchQueryInput());
|
||||
});
|
||||
}
|
||||
|
||||
readonly playlistsData$ = combineLatest([
|
||||
this.store.select(selectAllPlaylistsMeta),
|
||||
this.searchQuery,
|
||||
this.store.select(selectActiveTypeFilters),
|
||||
this.sortService.getSortOptions(),
|
||||
]).pipe(
|
||||
map(([playlists, searchQuery, filters, sortOptions]) => {
|
||||
const filteredPlaylists = playlists
|
||||
.filter((item) => {
|
||||
const isStalkerFilter =
|
||||
item.macAddress && filters.includes('stalker');
|
||||
const isXtreamFilter =
|
||||
item.username &&
|
||||
item.password &&
|
||||
item.serverUrl &&
|
||||
filters.includes('xtream');
|
||||
const isM3uFilter =
|
||||
!item.username &&
|
||||
!item.password &&
|
||||
!item.serverUrl &&
|
||||
!item.macAddress &&
|
||||
filters.includes('m3u');
|
||||
|
||||
return (
|
||||
(isStalkerFilter && filters.includes('stalker')) ||
|
||||
(isXtreamFilter && filters.includes('xtream')) ||
|
||||
(isM3uFilter && filters.includes('m3u'))
|
||||
);
|
||||
})
|
||||
.filter((item) =>
|
||||
(item.title || '')
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Apply sorting using the SortService
|
||||
const sortedPlaylists = this.sortService.sortPlaylists(
|
||||
filteredPlaylists,
|
||||
sortOptions
|
||||
);
|
||||
|
||||
return {
|
||||
playlists: sortedPlaylists,
|
||||
totalCount: playlists.length,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
readonly playlistsData = toSignal(this.playlistsData$, {
|
||||
initialValue: {
|
||||
playlists: [] as PlaylistMeta[],
|
||||
totalCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens the details dialog with the information about the provided playlist
|
||||
* @param data selected playlist
|
||||
*/
|
||||
openInfoDialog(data: PlaylistMeta): void {
|
||||
this.dialog.open(PlaylistInfoComponent, {
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop event handler - applies the new sort order to the playlists array
|
||||
* @param event drop event
|
||||
*/
|
||||
drop(event: CdkDragDrop<PlaylistMeta[]>, playlists: PlaylistMeta[]): void {
|
||||
moveItemInArray(playlists, event.previousIndex, event.currentIndex);
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistPositions({
|
||||
positionUpdates: playlists.map((item, index) => ({
|
||||
id: item._id,
|
||||
changes: { position: index },
|
||||
})),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onAddPlaylist(type?: WorkspacePlaylistType) {
|
||||
this.addPlaylistClicked.emit(type);
|
||||
}
|
||||
|
||||
getPlaylist(playlistMeta: PlaylistMeta): void {
|
||||
if (playlistMeta.serverUrl) {
|
||||
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
|
||||
} else if (playlistMeta.macAddress) {
|
||||
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
|
||||
} else {
|
||||
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
|
||||
this.playlistClicked.emit(playlistMeta._id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers on remove click
|
||||
* @param playlistId playlist id to remove
|
||||
*/
|
||||
removeClicked(item: PlaylistMeta): void {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: () => {
|
||||
this.removePlaylist(item);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the provided playlist from the database
|
||||
* @param playlistId playlist id to remove
|
||||
*/
|
||||
async removePlaylist(item: PlaylistMeta) {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPendingDeletion(item._id, true);
|
||||
const operationId = item.serverUrl
|
||||
? this.databaseService.createOperationId('playlist-delete')
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const deleted = await this.databaseService.deletePlaylist(
|
||||
item._id,
|
||||
operationId
|
||||
? {
|
||||
operationId,
|
||||
onEvent: (event) =>
|
||||
this.updateBusyOperation(item._id, event),
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
if (deleted) {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({ playlistId: item._id })
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
|
||||
),
|
||||
undefined,
|
||||
{
|
||||
duration: 2000,
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyOperation(item._id);
|
||||
this.setPendingDeletion(item._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
|
||||
* @param item playlist to update
|
||||
*/
|
||||
refreshPlaylist(item: PlaylistMeta) {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.serverUrl) {
|
||||
// For Xtream playlists, delete and re-import
|
||||
this.refreshXtreamPlaylist(item);
|
||||
} else if (window.electron && (item.url || item.filePath)) {
|
||||
void this.refreshM3uPlaylist(item);
|
||||
} else {
|
||||
// For M3U playlists, use existing refresh logic
|
||||
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
|
||||
id: item._id,
|
||||
title: item.title,
|
||||
...(item.url ? { url: item.url } : { filePath: item.filePath }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Xtream playlist by deleting all data and re-importing from remote
|
||||
* @param item Xtream playlist to refresh
|
||||
*/
|
||||
async refreshXtreamPlaylist(item: PlaylistMeta) {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogService.openConfirmDialog({
|
||||
title: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
|
||||
),
|
||||
message: this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
|
||||
),
|
||||
onConfirm: async () => {
|
||||
if (
|
||||
this.isDeletePending(item._id) ||
|
||||
this.isRefreshPending(item._id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPendingRefresh(item._id, true);
|
||||
const operationId =
|
||||
this.databaseService.createOperationId('xtream-refresh');
|
||||
|
||||
try {
|
||||
// Show immediate feedback — deletion can take several seconds
|
||||
// for large playlists.
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
|
||||
// Delete content/categories and update the timestamp in
|
||||
// parallel — both operations are fully independent.
|
||||
const updateDate = Date.now();
|
||||
const [restoreState, playbackPositions] = await Promise.all(
|
||||
[
|
||||
this.databaseService.deleteXtreamPlaylistContent(
|
||||
item._id,
|
||||
{
|
||||
operationId,
|
||||
onEvent: (workerEvent) =>
|
||||
this.updateBusyOperation(
|
||||
item._id,
|
||||
workerEvent
|
||||
),
|
||||
}
|
||||
),
|
||||
this.playbackPositionService.getAllPlaybackPositions(
|
||||
item._id
|
||||
),
|
||||
this.databaseService.updateXtreamPlaylistDetails({
|
||||
id: item._id,
|
||||
updateDate,
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
this.pendingRestoreService.set(item._id, {
|
||||
...restoreState,
|
||||
playbackPositions,
|
||||
});
|
||||
|
||||
// Update the timestamp in NgRx / IndexedDB
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
);
|
||||
|
||||
// Navigate to the playlist to trigger re-import
|
||||
this.router.navigate(['/workspace', 'xtreams', item._id]);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
console.error(
|
||||
'Error refreshing Xtream playlist:',
|
||||
error
|
||||
);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyOperation(item._id);
|
||||
this.setPendingRefresh(item._id, false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshM3uPlaylist(item: PlaylistMeta): Promise<void> {
|
||||
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setPendingRefresh(item._id, true);
|
||||
const operationId =
|
||||
this.databaseService.createOperationId('playlist-refresh');
|
||||
|
||||
try {
|
||||
const refreshedPlaylist =
|
||||
await this.playlistRefreshService.refreshPlaylist(
|
||||
{
|
||||
operationId,
|
||||
playlistId: item._id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
filePath: item.filePath,
|
||||
},
|
||||
{
|
||||
onEvent: (event) =>
|
||||
this.updateBusyOperation(
|
||||
item._id,
|
||||
this.toPlaylistRefreshBusyEvent(event)
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
this.updateBusyOperation(item._id, {
|
||||
operationId,
|
||||
operation: 'playlist-refresh',
|
||||
playlistId: item._id,
|
||||
phase: 'saving',
|
||||
status: 'progress',
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylist({
|
||||
playlist: {
|
||||
...refreshedPlaylist,
|
||||
_id: item._id,
|
||||
},
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
|
||||
this.clearBusyOperation(item._id);
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
|
||||
),
|
||||
null,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isDbAbortError(error)) {
|
||||
console.error('Error refreshing playlist:', error);
|
||||
this.snackBar.open(
|
||||
this.getPlaylistRefreshErrorMessage(error, item),
|
||||
this.translate.instant('CLOSE'),
|
||||
{ duration: 5000 }
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
this.clearBusyOperation(item._id);
|
||||
this.setPendingRefresh(item._id, false);
|
||||
}
|
||||
}
|
||||
|
||||
isDeletePending(playlistId: string): boolean {
|
||||
return this.pendingDeletionIds().has(playlistId);
|
||||
}
|
||||
|
||||
isRefreshPending(playlistId: string): boolean {
|
||||
return this.pendingRefreshIds().has(playlistId);
|
||||
}
|
||||
|
||||
getBusyMessage(item: PlaylistMeta): string {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (operation.operation) {
|
||||
case 'delete-playlist':
|
||||
return this.translateDeletePhase(operation.phase);
|
||||
case 'delete-xtream-content':
|
||||
return this.translateRefreshPhase(operation.phase);
|
||||
case 'playlist-refresh':
|
||||
return this.translatePlaylistRefreshPhase(operation.phase);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getBusyProgress(playlistId: string): number | null {
|
||||
const operation = this.busyOperations().get(playlistId);
|
||||
if (
|
||||
!operation ||
|
||||
operation.current == null ||
|
||||
operation.total == null ||
|
||||
operation.total <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((operation.current / operation.total) * 100)
|
||||
);
|
||||
}
|
||||
|
||||
canCancelBusyOperation(item: PlaylistMeta): boolean {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation?.operationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (operation.operation === 'playlist-refresh') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(item.serverUrl);
|
||||
}
|
||||
|
||||
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
|
||||
const operation = this.busyOperations().get(item._id);
|
||||
if (!operation?.operationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation.operation === 'playlist-refresh') {
|
||||
await this.playlistRefreshService.cancelRefresh(
|
||||
operation.operationId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.databaseService.cancelOperation(operation.operationId);
|
||||
}
|
||||
|
||||
private setPendingDeletion(playlistId: string, isPending: boolean): void {
|
||||
this.pendingDeletionIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (isPending) {
|
||||
next.add(playlistId);
|
||||
} else {
|
||||
next.delete(playlistId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private setPendingRefresh(playlistId: string, isPending: boolean): void {
|
||||
this.pendingRefreshIds.update((current) => {
|
||||
const next = new Set(current);
|
||||
if (isPending) {
|
||||
next.add(playlistId);
|
||||
} else {
|
||||
next.delete(playlistId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private updateBusyOperation(
|
||||
playlistId: string,
|
||||
event: DbOperationEvent
|
||||
): void {
|
||||
this.busyOperations.update((current) => {
|
||||
const next = new Map(current);
|
||||
|
||||
if (
|
||||
event.status === 'completed' ||
|
||||
event.status === 'cancelled' ||
|
||||
event.status === 'error'
|
||||
) {
|
||||
next.delete(playlistId);
|
||||
return next;
|
||||
}
|
||||
|
||||
next.set(playlistId, {
|
||||
operation: event.operation,
|
||||
operationId: event.operationId,
|
||||
phase: event.phase,
|
||||
current: event.current,
|
||||
total: event.total,
|
||||
status: event.status,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private clearBusyOperation(playlistId: string): void {
|
||||
this.busyOperations.update((current) => {
|
||||
const next = new Map(current);
|
||||
next.delete(playlistId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
private toPlaylistRefreshBusyEvent(
|
||||
event: PlaylistRefreshEvent
|
||||
): DbOperationEvent {
|
||||
return {
|
||||
operationId: event.operationId,
|
||||
operation: 'playlist-refresh',
|
||||
playlistId: event.playlistId,
|
||||
phase: event.phase,
|
||||
status: event.status,
|
||||
error: event.error,
|
||||
};
|
||||
}
|
||||
|
||||
private translateDeletePhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'deleting-favorites':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES'
|
||||
);
|
||||
case 'deleting-recently-viewed':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT'
|
||||
);
|
||||
case 'deleting-playback-positions':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS'
|
||||
);
|
||||
case 'deleting-downloads':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS'
|
||||
);
|
||||
case 'deleting-content':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
case 'deleting-categories':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES'
|
||||
);
|
||||
case 'deleting-playlist':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST'
|
||||
);
|
||||
default:
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private translateRefreshPhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'collecting-user-data':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
|
||||
);
|
||||
case 'deleting-content':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT'
|
||||
);
|
||||
case 'deleting-categories':
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES'
|
||||
);
|
||||
default:
|
||||
return this.translate.instant(
|
||||
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private translatePlaylistRefreshPhase(phase?: string): string {
|
||||
switch (phase) {
|
||||
case 'fetching':
|
||||
return this.translateWithFallback(
|
||||
'HOME.PLAYLISTS.REFRESH_FETCHING',
|
||||
'Fetching playlist...'
|
||||
);
|
||||
case 'reading-file':
|
||||
return this.translateWithFallback(
|
||||
'HOME.PLAYLISTS.REFRESH_READING_FILE',
|
||||
'Reading playlist file...'
|
||||
);
|
||||
case 'parsing':
|
||||
return this.translateWithFallback(
|
||||
'HOME.PLAYLISTS.REFRESH_PARSING',
|
||||
'Parsing playlist...'
|
||||
);
|
||||
case 'saving':
|
||||
return this.translateWithFallback(
|
||||
'HOME.PLAYLISTS.REFRESH_SAVING',
|
||||
'Saving playlist...'
|
||||
);
|
||||
default:
|
||||
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
|
||||
}
|
||||
}
|
||||
|
||||
private getPlaylistRefreshErrorMessage(
|
||||
error: unknown,
|
||||
item: PlaylistMeta
|
||||
): string {
|
||||
if (item.filePath) {
|
||||
const message = String(
|
||||
(error as { message?: string })?.message ?? error
|
||||
);
|
||||
|
||||
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
|
||||
return this.translateWithFallback(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND',
|
||||
'Playlist refresh failed. The local file is no longer available. Check the file path or re-import the playlist.'
|
||||
);
|
||||
}
|
||||
|
||||
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
|
||||
return this.translateWithFallback(
|
||||
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR',
|
||||
'Playlist refresh failed. The app can no longer access the local file.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
|
||||
}
|
||||
|
||||
private translateWithFallback(key: string, fallback: string): string {
|
||||
const translated = this.translate.instant(key);
|
||||
return translated === key ? fallback : translated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"target": "es2016",
|
||||
"types": ["jest", "node"],
|
||||
"moduleResolution": "node10"
|
||||
},
|
||||
"files": ["src/test-setup.ts"],
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user