Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+21
View File
@@ -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',
],
};
+21
View File
@@ -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"
}
}
}
+6
View File
@@ -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;
}
}
@@ -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.
}
}
}
@@ -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>
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -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>
@@ -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 }
);
}
}
}
}
@@ -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>
@@ -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;
}
}
@@ -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();
});
});
@@ -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,
});
+28
View File
@@ -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"
}
]
}
+12
View File
@@ -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"
]
}
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'playlist-shared-util',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/playlist/shared/util',
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',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "playlist-shared-util",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/shared/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:util"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/playlist/shared/util/jest.config.ts",
"tsConfig": "libs/playlist/shared/util/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './lib/playlist-player-actions';
export * from './lib/playlist-context.facade';
export * from './lib/playlist-file-import.service';
export * from './lib/playlist-refresh-action.service';
@@ -0,0 +1,422 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import {
PlaylistActions,
selectActivePlaylistId,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { Subject } from 'rxjs';
import { PlaylistMeta } from 'shared-interfaces';
import {
PlaylistContextFacade,
PlaylistRouteContext,
} from './playlist-context.facade';
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2';
function createPlaylist(
overrides: Partial<PlaylistMeta> & { _id: string }
): PlaylistMeta {
return {
_id: overrides._id,
title: overrides.title ?? overrides.filename ?? overrides._id,
count: overrides.count ?? 0,
importDate:
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
autoRefresh: overrides.autoRefresh ?? false,
...overrides,
} as PlaylistMeta;
}
describe('PlaylistContextFacade', () => {
let facade: PlaylistContextFacade;
let routerEvents: Subject<NavigationEnd>;
let router: {
url: string;
events: ReturnType<Subject<NavigationEnd>['asObservable']>;
navigate: jest.Mock;
};
let dispatch: jest.Mock;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let loadedSignal: ReturnType<typeof signal<boolean>>;
let activePlaylistIdSignal: ReturnType<typeof signal<string | null>>;
let originalElectron: unknown;
const xtreamA = createPlaylist({
_id: 'xtream-a',
password: 'secret',
serverUrl: 'http://127.0.0.1:3211',
title: 'Xtream A',
username: 'user-a',
});
const xtreamB = createPlaylist({
_id: 'xtream-b',
password: 'secret',
serverUrl: 'http://127.0.0.1:3211',
title: 'Xtream B',
username: 'user-b',
});
const stalkerA = createPlaylist({
_id: 'stalker-a',
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Stalker A',
});
const stalkerB = createPlaylist({
_id: 'stalker-b',
macAddress: '00:1A:79:00:00:02',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Stalker B',
});
const m3uA = createPlaylist({
_id: 'm3u-a',
title: 'M3U A',
url: 'http://example.test/a.m3u',
});
const m3uB = createPlaylist({
_id: 'm3u-b',
title: 'M3U B',
url: 'http://example.test/b.m3u',
});
function setElectronAvailability(enabled: boolean): void {
Object.defineProperty(window, 'electron', {
configurable: true,
value: enabled ? {} : undefined,
});
}
function instantiateFacade(): PlaylistContextFacade {
facade = TestBed.inject(PlaylistContextFacade);
dispatch.mockClear();
router.navigate.mockClear();
return facade;
}
beforeEach(() => {
localStorage.clear();
originalElectron = (window as Window & { electron?: unknown }).electron;
routerEvents = new Subject<NavigationEnd>();
router = {
url: '/workspace/xtreams/xtream-a/live',
events: routerEvents.asObservable(),
navigate: jest.fn().mockResolvedValue(true),
};
dispatch = jest.fn((action: unknown) => action);
playlistsSignal = signal([
xtreamA,
xtreamB,
stalkerA,
stalkerB,
m3uA,
m3uB,
]);
loadedSignal = signal(true);
activePlaylistIdSignal = signal<string | null>(xtreamA._id);
setElectronAvailability(true);
TestBed.configureTestingModule({
providers: [
PlaylistContextFacade,
{
provide: Router,
useValue: router,
},
{
provide: Store,
useValue: {
dispatch,
selectSignal: jest.fn((selector: unknown) => {
if (selector === selectAllPlaylistsMeta) {
return playlistsSignal;
}
if (selector === selectPlaylistsLoadingFlag) {
return loadedSignal;
}
if (selector === selectActivePlaylistId) {
return activePlaylistIdSignal;
}
throw new Error(
`Unexpected selector: ${String(selector)}`
);
}),
},
},
],
});
});
afterEach(() => {
localStorage.clear();
Object.defineProperty(window, 'electron', {
configurable: true,
value: originalElectron,
});
routerEvents.complete();
jest.restoreAllMocks();
});
it('preserves same-provider Xtream sections when switching through selectPlaylist', () => {
router.url = '/workspace/xtreams/xtream-a/live';
const service = instantiateFacade();
service.selectPlaylist(xtreamB);
expect(dispatch).toHaveBeenCalledWith(
PlaylistActions.setActivePlaylist({ playlistId: xtreamB._id })
);
expect(router.navigate).toHaveBeenCalledWith([
'workspace',
'xtreams',
xtreamB._id,
'live',
]);
});
it('preserves same-provider Stalker sections when resolving target commands', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('stalker', stalkerB._id, {
inWorkspace: true,
provider: 'stalker',
playlistId: stalkerA._id,
section: 'downloads',
})
).toEqual(['workspace', 'stalker', stalkerB._id, 'downloads']);
});
it('preserves same-provider M3U sections when resolving target commands', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('playlists', m3uB._id, {
inWorkspace: true,
provider: 'playlists',
playlistId: m3uA._id,
section: 'groups',
})
).toEqual(['workspace', 'playlists', m3uB._id, 'groups']);
});
it('maps Stalker ITV routes to Xtream live routes', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('xtreams', xtreamA._id, {
inWorkspace: true,
provider: 'stalker',
playlistId: stalkerA._id,
section: 'itv',
})
).toEqual(['workspace', 'xtreams', xtreamA._id, 'live']);
});
it('maps Xtream live routes to Stalker ITV routes', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('stalker', stalkerA._id, {
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: 'live',
})
).toEqual(['workspace', 'stalker', stalkerA._id, 'itv']);
});
it('maps Xtream recently-added routes to Stalker recent routes', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('stalker', stalkerA._id, {
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: 'recently-added',
})
).toEqual(['workspace', 'stalker', stalkerA._id, 'recent']);
});
it('preserves recent and favorites sections across provider changes when both providers support them', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('xtreams', xtreamA._id, {
inWorkspace: true,
provider: 'playlists',
playlistId: m3uA._id,
section: 'recent',
})
).toEqual(['workspace', 'xtreams', xtreamA._id, 'recent']);
expect(
service.resolveTargetCommands('stalker', stalkerA._id, {
inWorkspace: true,
provider: 'playlists',
playlistId: m3uA._id,
section: 'favorites',
})
).toEqual(['workspace', 'stalker', stalkerA._id, 'favorites']);
});
it('falls back to M3U all when the current section has no M3U equivalent', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('playlists', m3uA._id, {
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: 'live',
})
).toEqual(['workspace', 'playlists', m3uA._id, 'all']);
});
it('does not let stale M3U memory override the fallback to all for portal-only sections', () => {
localStorage.setItem(
LAST_SECTION_STORAGE_KEY,
JSON.stringify({
providers: {
playlists: 'recent',
},
playlists: {
[m3uA._id]: {
provider: 'playlists',
section: 'recent',
updatedAt: 1,
},
},
})
);
const service = instantiateFacade();
expect(
service.resolveTargetCommands('playlists', m3uA._id, {
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: 'live',
})
).toEqual(['workspace', 'playlists', m3uA._id, 'all']);
});
it('uses playlist-specific section memory when the current route has no section', () => {
localStorage.setItem(
LAST_SECTION_STORAGE_KEY,
JSON.stringify({
providers: {},
playlists: {
[xtreamB._id]: {
provider: 'xtreams',
section: 'series',
updatedAt: 1,
},
},
})
);
const service = instantiateFacade();
expect(
service.resolveTargetCommands('xtreams', xtreamB._id, {
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: null,
})
).toEqual(['workspace', 'xtreams', xtreamB._id, 'series']);
});
it('uses provider-level section memory when no route section or playlist memory exists', () => {
localStorage.setItem(
LAST_SECTION_STORAGE_KEY,
JSON.stringify({
providers: {
stalker: 'search',
},
playlists: {},
})
);
const service = instantiateFacade();
expect(
service.resolveTargetCommands('stalker', stalkerB._id, {
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: null,
})
).toEqual(['workspace', 'stalker', stalkerB._id, 'search']);
});
it('omits Xtream sections outside Electron where section navigation is unsupported', () => {
setElectronAvailability(false);
const service = instantiateFacade();
expect(
service.resolveTargetCommands('xtreams', xtreamB._id, {
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: 'live',
})
).toEqual(['workspace', 'xtreams', xtreamB._id]);
});
it('returns null when switching from the workspace root before a provider route is selected', () => {
const service = instantiateFacade();
expect(
service.resolveTargetCommands('playlists', m3uA._id, {
inWorkspace: true,
provider: null,
playlistId: null,
section: null,
})
).toBeNull();
});
it('syncs section memory from router navigation events', () => {
router.url = '/workspace/playlists/m3u-a/favorites';
const service = instantiateFacade();
router.url = '/workspace/xtreams/xtream-a/recently-added';
routerEvents.next(
new NavigationEnd(
1,
'/workspace/xtreams/xtream-a/recently-added',
'/workspace/xtreams/xtream-a/recently-added'
)
);
expect(service.routeContext()).toEqual<PlaylistRouteContext>({
inWorkspace: true,
provider: 'xtreams',
playlistId: xtreamA._id,
section: 'recently-added',
});
expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}'))
.toEqual({
playlists: {
[m3uA._id]: {
provider: 'playlists',
section: 'favorites',
updatedAt: expect.any(Number),
},
[xtreamA._id]: {
provider: 'xtreams',
section: 'recently-added',
updatedAt: expect.any(Number),
},
},
providers: {
playlists: 'favorites',
xtreams: 'recently-added',
},
});
});
});
@@ -0,0 +1,566 @@
import {
computed,
DestroyRef,
effect,
inject,
Injectable,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import {
PlaylistActions,
selectActivePlaylistId,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { filter } from 'rxjs';
import { PlaylistMeta } from 'shared-interfaces';
import {
PortalProvider,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
export interface PlaylistRouteContext {
inWorkspace: boolean;
provider: PortalProvider | null;
playlistId: string | null;
section: PortalRailSection | null;
}
type PlaylistSectionMemory = {
providers: Partial<Record<PortalProvider, string>>;
playlists: Record<
string,
{
provider: PortalProvider;
section: string;
updatedAt: number;
}
>;
};
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2';
const LEGACY_LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections';
const LAST_ACTIVE_PLAYLIST_STORAGE_KEY =
'playlist-switcher:last-active-playlist-id';
const XTREAM_SECTIONS = [
'live',
'vod',
'series',
'favorites',
'recent',
'search',
'recently-added',
'downloads',
] as const;
const STALKER_SECTIONS = [
'itv',
'vod',
'series',
'favorites',
'recent',
'search',
'downloads',
] as const;
const M3U_SECTIONS = ['all', 'groups', 'favorites', 'recent'] as const;
@Injectable({ providedIn: 'root' })
export class PlaylistContextFacade {
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly store = inject(Store);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId);
readonly currentUrl = signal(this.router.url);
readonly routeContext = computed(() =>
this.getRouteContext(this.currentUrl())
);
readonly routeProvider = computed(() => this.routeContext().provider);
readonly routePlaylistId = computed(() => this.routeContext().playlistId);
readonly routeSection = computed(() => this.routeContext().section);
readonly resolvedPlaylistId = computed(() => {
const routePlaylistId = this.routePlaylistId();
if (routePlaylistId) {
return routePlaylistId;
}
const activePlaylistId = this.activePlaylistId();
return activePlaylistId || null;
});
readonly activePlaylist = computed(() => {
const playlistId = this.resolvedPlaylistId();
if (!playlistId) {
return null;
}
return (
this.playlists().find((playlist) => playlist._id === playlistId) ??
null
);
});
readonly activeProvider = computed(() => {
const playlist = this.activePlaylist();
return playlist ? this.getProviderForPlaylist(playlist) : null;
});
constructor() {
this.clearLegacySectionMemory();
this.syncFromUrl(this.router.url);
effect(() => {
const allPlaylistsLoaded = this.allPlaylistsLoaded();
const playlistIds = this.playlists().map(
(playlist) => playlist._id
);
const playlistIdSet = new Set(playlistIds);
const selectedId = this.activePlaylistId() || null;
const routeId = this.routePlaylistId();
const storedId = this.readPersistedActivePlaylistId();
if (!allPlaylistsLoaded) {
if (routeId) {
this.writePersistedActivePlaylistId(routeId);
return;
}
if (selectedId) {
this.writePersistedActivePlaylistId(selectedId);
}
return;
}
this.removeDeletedPlaylistsFromSectionMemory(playlistIds);
if (routeId && playlistIdSet.has(routeId)) {
this.writePersistedActivePlaylistId(routeId);
return;
}
if (selectedId && playlistIdSet.has(selectedId)) {
this.writePersistedActivePlaylistId(selectedId);
return;
}
if (
!routeId &&
!selectedId &&
storedId &&
playlistIdSet.has(storedId)
) {
this.activatePlaylist(storedId);
return;
}
if (storedId && !playlistIdSet.has(storedId)) {
this.clearPersistedActivePlaylistId();
}
});
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((event) => {
this.syncFromUrl(event.urlAfterRedirects);
});
}
syncFromUrl(url = this.router.url): PlaylistRouteContext {
this.currentUrl.set(url);
const routeContext = this.routeContext();
this.rememberSection(routeContext);
if (routeContext.playlistId) {
this.activatePlaylist(routeContext.playlistId);
}
return routeContext;
}
activatePlaylist(playlistId: string): void {
if (!playlistId || this.activePlaylistId() === playlistId) {
return;
}
this.store.dispatch(PlaylistActions.setActivePlaylist({ playlistId }));
}
selectPlaylist(playlist: PlaylistMeta): void {
const routeContext = this.routeContext();
const provider = this.getProviderForPlaylist(playlist);
this.activatePlaylist(playlist._id);
const targetCommands = this.resolveTargetCommands(
provider,
playlist._id,
routeContext
);
if (targetCommands) {
void this.router.navigate(targetCommands);
}
}
resolveTargetCommands(
provider: PortalProvider,
playlistId: string,
routeContext = this.routeContext()
): string[] | null {
const prefix = routeContext.inWorkspace ? ['workspace'] : [];
if (routeContext.inWorkspace && !routeContext.provider) {
return null;
}
if (provider === 'playlists') {
const section = this.resolveTargetSection(
provider,
routeContext.section,
playlistId
);
return [...prefix, 'playlists', playlistId, section];
}
if (!this.supportsSectionNavigation(provider)) {
return [...prefix, provider, playlistId];
}
const section = this.resolveTargetSection(
provider,
routeContext.section,
playlistId
);
return [...prefix, provider, playlistId, section];
}
getProviderForPlaylist(playlist: PlaylistMeta): PortalProvider {
if (playlist.serverUrl) {
return 'xtreams';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'playlists';
}
private isWorkspaceRoute(url: string): boolean {
return /^\/workspace(?:\/|$)/.test(url);
}
private getRouteContext(url: string): PlaylistRouteContext {
const match = url.match(
/^\/(?:workspace\/)?(playlists|xtreams|stalker)\/([^/?]+)(?:\/([^/?]+))?/
);
return {
inWorkspace: this.isWorkspaceRoute(url),
provider: (match?.[1] as PortalProvider | undefined) ?? null,
playlistId: match?.[2] ?? null,
section: (match?.[3] as PortalRailSection | undefined) ?? null,
};
}
private rememberSection(routeContext: PlaylistRouteContext): void {
if (!routeContext.provider || !routeContext.section) {
return;
}
const normalized = this.normalizeSectionForProvider(
routeContext.section,
routeContext.provider
);
if (!normalized) {
return;
}
const memory = this.readSectionMemory();
memory.providers[routeContext.provider] = normalized;
if (routeContext.playlistId) {
memory.playlists[routeContext.playlistId] = {
provider: routeContext.provider,
section: normalized,
updatedAt: Date.now(),
};
}
this.writeSectionMemory(memory);
}
private resolveTargetSection(
provider: PortalProvider,
currentSection: PortalRailSection | null,
targetPlaylistId: string
): string {
const fromCurrent = this.normalizeSectionForProvider(
currentSection,
provider
);
if (fromCurrent) {
return fromCurrent;
}
if (provider === 'playlists' && currentSection) {
return 'all';
}
const memory = this.readSectionMemory();
const playlistMemory = memory.playlists[targetPlaylistId];
if (playlistMemory?.provider === provider) {
const normalizedPlaylistSection = this.normalizeSectionForProvider(
playlistMemory.section,
provider
);
if (normalizedPlaylistSection) {
return normalizedPlaylistSection;
}
}
const providerMemory = memory.providers[provider];
const normalizedProviderSection = this.normalizeSectionForProvider(
providerMemory,
provider
);
if (normalizedProviderSection) {
return normalizedProviderSection;
}
if (provider === 'playlists') {
return 'all';
}
return 'vod';
}
private supportsSectionNavigation(provider: PortalProvider): boolean {
if (provider === 'xtreams') {
return Boolean(window.electron);
}
return true;
}
private normalizeSectionForProvider(
section: string | null | undefined,
provider: PortalProvider
): string | null {
if (!section) {
return null;
}
if (provider === 'xtreams') {
if (section === 'itv') {
return 'live';
}
return XTREAM_SECTIONS.includes(
section as (typeof XTREAM_SECTIONS)[number]
)
? section
: null;
}
if (provider === 'playlists') {
return M3U_SECTIONS.includes(
section as (typeof M3U_SECTIONS)[number]
)
? section
: null;
}
if (section === 'live') {
return 'itv';
}
if (section === 'recently-added') {
return 'recent';
}
return STALKER_SECTIONS.includes(
section as (typeof STALKER_SECTIONS)[number]
)
? section
: null;
}
private readSectionMemory(): PlaylistSectionMemory {
const empty: PlaylistSectionMemory = { providers: {}, playlists: {} };
try {
this.clearLegacySectionMemory();
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
if (!raw) {
return empty;
}
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) {
return empty;
}
const providers: Partial<Record<PortalProvider, string>> = {};
const playlists: PlaylistSectionMemory['playlists'] = {};
const parsedProviders = (parsed as Record<string, unknown>)[
'providers'
];
if (typeof parsedProviders === 'object' && parsedProviders !== null) {
const candidate = parsedProviders as Record<string, unknown>;
if (typeof candidate['playlists'] === 'string') {
providers.playlists = candidate['playlists'];
}
if (typeof candidate['xtreams'] === 'string') {
providers.xtreams = candidate['xtreams'];
}
if (typeof candidate['stalker'] === 'string') {
providers.stalker = candidate['stalker'];
}
}
const parsedPlaylists = (parsed as Record<string, unknown>)[
'playlists'
];
if (
typeof parsedPlaylists === 'object' &&
parsedPlaylists !== null
) {
Object.entries(
parsedPlaylists as Record<string, unknown>
).forEach(([playlistId, value]) => {
if (!value || typeof value !== 'object' || !playlistId) {
return;
}
const row = value as Record<string, unknown>;
const provider = row['provider'];
const section = row['section'];
const updatedAt = row['updatedAt'];
const isProviderValid =
provider === 'playlists' ||
provider === 'xtreams' ||
provider === 'stalker';
if (!isProviderValid || typeof section !== 'string') {
return;
}
playlists[playlistId] = {
provider,
section,
updatedAt:
typeof updatedAt === 'number'
? updatedAt
: Date.now(),
};
});
}
return { providers, playlists };
} catch {
return empty;
}
}
private writeSectionMemory(memory: PlaylistSectionMemory): void {
try {
localStorage.setItem(
LAST_SECTION_STORAGE_KEY,
JSON.stringify(memory)
);
} catch {
// Ignore storage write failures.
}
}
private removeDeletedPlaylistsFromSectionMemory(
existingIds: string[]
): void {
const existingIdSet = new Set(existingIds);
const memory = this.readSectionMemory();
const currentEntries = Object.entries(memory.playlists);
if (currentEntries.length === 0) {
return;
}
const nextPlaylists: PlaylistSectionMemory['playlists'] = {};
let hasChanges = false;
currentEntries.forEach(([playlistId, entry]) => {
if (!existingIdSet.has(playlistId)) {
hasChanges = true;
return;
}
const normalizedSection = this.normalizeSectionForProvider(
entry.section,
entry.provider
);
if (!normalizedSection) {
hasChanges = true;
return;
}
nextPlaylists[playlistId] = {
...entry,
section: normalizedSection,
};
});
if (!hasChanges) {
return;
}
this.writeSectionMemory({
providers: memory.providers,
playlists: nextPlaylists,
});
}
private readPersistedActivePlaylistId(): string | null {
try {
const value = localStorage.getItem(
LAST_ACTIVE_PLAYLIST_STORAGE_KEY
);
return value && value.trim().length > 0 ? value : null;
} catch {
return null;
}
}
private writePersistedActivePlaylistId(playlistId: string): void {
try {
localStorage.setItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY, playlistId);
} catch {
// Ignore storage write failures.
}
}
private clearPersistedActivePlaylistId(): void {
try {
localStorage.removeItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY);
} catch {
// Ignore storage write failures.
}
}
private clearLegacySectionMemory(): void {
try {
if (!localStorage.getItem(LEGACY_LAST_SECTION_STORAGE_KEY)) {
return;
}
localStorage.removeItem(LEGACY_LAST_SECTION_STORAGE_KEY);
} catch {
// Ignore storage read/write failures.
}
}
}
@@ -0,0 +1,56 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
const M3U_EXTENSIONS = ['.m3u', '.m3u8'];
export type PlaylistFileImportResult =
| { ok: true; title: string }
| { ok: false; reason: 'unsupported' | 'empty' | 'read-error' };
@Injectable({ providedIn: 'root' })
export class PlaylistFileImportService {
private readonly store = inject(Store);
isSupportedFile(file: File): boolean {
const lower = file.name.toLowerCase();
return M3U_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
async importFile(file: File): Promise<PlaylistFileImportResult> {
if (!this.isSupportedFile(file)) {
return { ok: false, reason: 'unsupported' };
}
let playlist: string;
try {
playlist = await file.text();
} catch {
return { ok: false, reason: 'read-error' };
}
if (!playlist.trim()) {
return { ok: false, reason: 'empty' };
}
const title = this.normalizeTitle(file.name);
this.store.dispatch(
PlaylistActions.parsePlaylist({
uploadType: 'FILE',
playlist,
title,
path: (file as File & { path?: string }).path,
})
);
return { ok: true, title };
}
private normalizeTitle(filename: string): string {
const trimmed = filename.trim();
if (!trimmed) {
return filename;
}
return trimmed.replace(/\.(m3u8?|pls|txt)$/i, '') || trimmed;
}
}
@@ -0,0 +1,8 @@
import { InjectionToken } from '@angular/core';
export interface PlaylistPlayerActions {
openSettings(): void;
}
export const PLAYLIST_PLAYER_ACTIONS =
new InjectionToken<PlaylistPlayerActions>('PLAYLIST_PLAYER_ACTIONS');
@@ -0,0 +1,272 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { DialogService } from 'components';
import {
DatabaseService,
PlaybackPositionService,
PlaylistRefreshService,
} from 'services';
import { ChannelActions, PlaylistActions } from 'm3u-state';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
import { PlaylistContextFacade } from './playlist-context.facade';
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Xtream Playlist',
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
...overrides,
} as PlaylistMeta;
}
describe('PlaylistRefreshActionService', () => {
let service: PlaylistRefreshActionService;
let databaseService: {
createOperationId: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let router: {
navigate: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: {
dispatch: jest.Mock;
};
let playlistRefreshService: {
refreshPlaylist: jest.Mock;
};
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let routeProvider: ReturnType<
typeof signal<'playlists' | 'xtreams' | null>
>;
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
beforeEach(() => {
localStorage.clear();
databaseService = {
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
success: true,
favorites: [
{
xtreamId: 101,
contentType: 'live',
},
{
xtreamId: 202,
contentType: 'movie',
},
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-04T08:00:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
}),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(true),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
router = {
navigate: jest.fn().mockResolvedValue(true),
};
snackBar = {
open: jest.fn(),
};
store = {
dispatch: jest.fn(),
};
playlistRefreshService = {
refreshPlaylist: jest.fn(),
};
playbackPositionService = {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
routeProvider = signal<'playlists' | 'xtreams' | null>('xtreams');
resolvedPlaylistId = signal<string | null>(null);
TestBed.configureTestingModule({
providers: [
PlaylistRefreshActionService,
{
provide: Router,
useValue: router,
},
{
provide: Store,
useValue: store,
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
},
{
provide: PlaybackPositionService,
useValue: playbackPositionService,
},
{
provide: PlaylistContextFacade,
useValue: {
routeProvider,
resolvedPlaylistId,
},
},
],
});
service = TestBed.inject(PlaylistRefreshActionService);
});
afterEach(() => {
jest.restoreAllMocks();
localStorage.clear();
});
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
const item = createPlaylistMeta();
const executionOrder: string[] = [];
let confirmPromise: Promise<void> | undefined;
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712217600000);
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);
});
store.dispatch.mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
service.refresh(item);
await confirmPromise;
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-04T08:00:00.000Z',
},
],
playbackPositions: [],
})
);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712217600000 },
})
);
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
it('marks the active M3U route as loading before refreshing and clears loading after the update action', async () => {
const item = createPlaylistMeta({
_id: 'playlist-1',
filePath: '/tmp/playlist.m3u',
serverUrl: undefined,
url: 'https://example.com/playlist.m3u',
});
const refreshedPlaylist = {
_id: item._id,
playlist: {
items: [],
},
} as Playlist;
routeProvider.set('playlists');
resolvedPlaylistId.set(item._id);
playlistRefreshService.refreshPlaylist.mockResolvedValue(
refreshedPlaylist
);
service.refresh(item);
await Promise.resolve();
await Promise.resolve();
expect(store.dispatch).toHaveBeenNthCalledWith(
1,
ChannelActions.setChannelsLoading({ loading: true })
);
expect(store.dispatch).toHaveBeenNthCalledWith(
2,
PlaylistActions.updatePlaylist({
playlist: refreshedPlaylist,
playlistId: item._id,
})
);
});
});
@@ -0,0 +1,210 @@
import { inject, Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DialogService } from 'components';
import {
DatabaseService,
isDbAbortError,
PlaybackPositionService,
PlaylistRefreshService,
XtreamPendingRestoreService,
} from 'services';
import { ChannelActions, PlaylistActions } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistContextFacade } from './playlist-context.facade';
@Injectable({ providedIn: 'root' })
export class PlaylistRefreshActionService {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly dialogService = inject(DialogService);
private readonly databaseService = inject(DatabaseService);
private readonly playbackPositionService = inject(PlaybackPositionService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
readonly isRefreshing = signal(false);
canRefresh(playlist: PlaylistMeta | null): boolean {
if (!playlist || !window.electron) {
return false;
}
return Boolean(playlist.serverUrl || playlist.url || playlist.filePath);
}
refresh(playlist: PlaylistMeta): void {
if (this.isRefreshing()) {
return;
}
if (playlist.serverUrl) {
this.refreshXtream(playlist);
} else if (playlist.url || playlist.filePath) {
void this.refreshM3u(playlist);
}
}
private refreshXtream(item: PlaylistMeta): void {
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.isRefreshing()) {
return;
}
this.isRefreshing.set(true);
const operationId =
this.databaseService.createOperationId('xtream-refresh');
try {
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
const updateDate = Date.now();
const [restoreState, playbackPositions] = await Promise.all(
[
this.databaseService.deleteXtreamPlaylistContent(
item._id,
{ operationId }
),
this.playbackPositionService.getAllPlaybackPositions(
item._id
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]
);
this.pendingRestoreService.set(item._id, {
...restoreState,
playbackPositions,
});
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
await 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.isRefreshing.set(false);
}
},
});
}
private async refreshM3u(item: PlaylistMeta): Promise<void> {
const isActiveM3uRoute =
this.playlistContext.routeProvider() === 'playlists' &&
this.playlistContext.resolvedPlaylistId() === item._id;
this.isRefreshing.set(true);
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: true })
);
}
try {
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist({
operationId:
this.databaseService.createOperationId(
'playlist-refresh'
),
playlistId: item._id,
title: item.title,
url: item.url,
filePath: item.filePath,
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
if (isActiveM3uRoute) {
this.store.dispatch(
ChannelActions.setChannelsLoading({ loading: false })
);
}
} finally {
this.isRefreshing.set(false);
}
}
private getRefreshErrorMessage(error: unknown, item: PlaylistMeta): string {
if (
error instanceof Error &&
error.message?.includes('ENOENT') &&
item.filePath
) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+28
View File
@@ -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"
]
}