refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 23:24:34 +01:00
1 parent aa739f43e0
commit 7ac2fd66b8
175 files changed
+3118 -1076

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
export * from './lib/add-playlist-menu/add-playlist-menu.component';
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';
@@ -0,0 +1,43 @@
<mat-menu #addPlaylistMenu="matMenu">
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.FILE_UPLOAD' | translate"
(click)="onPlaylistTypeClick('file')"
>
<mat-icon>attach_file</mat-icon>
<span>{{ 'HOME.TABS.FILE_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.URL_UPLOAD' | translate"
(click)="onPlaylistTypeClick('url')"
>
<mat-icon>link</mat-icon>
<span>{{ 'HOME.TABS.URL_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.TEXT_IMPORT' | translate"
(click)="onPlaylistTypeClick('text')"
>
<mat-icon>text_format</mat-icon>
<span>{{ 'HOME.TABS.TEXT_IMPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.XTREME_IMPORT' | translate"
(click)="onPlaylistTypeClick('xtream')"
>
<mat-icon>video_library</mat-icon>
<span>{{ 'HOME.TABS.XTREME_IMPORT' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.STALKER_PORTAL_IMPORT' | translate"
(click)="onPlaylistTypeClick('stalker')"
>
<mat-icon>dashboard</mat-icon>
<span>{{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }}</span>
</button>
</mat-menu>
@@ -0,0 +1,22 @@
import { Component, output, viewChild } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
@Component({
selector: 'app-add-playlist-menu',
templateUrl: './add-playlist-menu.component.html',
imports: [MatDividerModule, MatIconModule, MatMenuModule, TranslateModule],
})
export class AddPlaylistMenuComponent {
readonly menu = viewChild.required<MatMenu>('addPlaylistMenu');
readonly playlistTypeSelected = output<PlaylistType>();
onPlaylistTypeClick(type: PlaylistType): void {
this.playlistTypeSelected.emit(type);
}
}
@@ -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,140 @@
<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>
<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>playlist_play</mat-icon>
<span>{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}</span>
</div>
<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>
<div class="type-filters" (click)="$event.stopPropagation()">
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('m3u')"
(click)="togglePlaylistTypeFilter('m3u', $event)"
>
M3U
</button>
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('stalker')"
(click)="togglePlaylistTypeFilter('stalker', $event)"
>
Stalker
</button>
<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">
{{ getPlaylistTypeLabel(playlist) }}
@if (playlist.updateDate) {
<span class="separator">|</span>
{{ playlist.updateDate | date: 'MMM d, HH:mm' }}
}
</span>
</div>
</div>
} @empty {
<div class="empty-state">
<mat-icon>playlist_add</mat-icon>
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
</div>
}
</div>
</mat-menu>
@@ -0,0 +1,386 @@
.playlist-switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
app-region: no-drag;
&:hover {
background: rgba(255, 255, 255, 0.13);
}
&: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: rgba(255, 255, 255, 0.08);
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;
max-width: 240px;
.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);
}
}
.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: auto;
&.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, 320px),
calc(100vw - 24px)
) !important;
min-width: min(
var(--playlist-switcher-overlay-width, 320px),
calc(100vw - 24px)
) !important;
max-width: min(
var(--playlist-switcher-overlay-width, 320px),
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: 14px 16px 10px;
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
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: rgba(255, 255, 255, 0.06);
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: 8px 0;
}
.playlist-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 6px 12px;
min-height: 52px;
transition: background-color 0.15s ease;
position: relative;
cursor: pointer;
}
.playlist-item.selected {
background: rgba(139, 92, 246, 0.1);
.playlist-title,
.playlist-meta {
color: #a78bfa;
}
}
.playlist-item:hover:not(.selected) {
background: var(--mat-sys-surface-container);
}
.playlist-icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
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;
}
}
@media only screen and (max-width: 480px) {
.playlist-info {
max-width: 140px;
.name {
font-size: 0.9em;
}
}
.playlist-icon-container {
display: none;
}
}
@@ -0,0 +1,850 @@
import { DatePipe, DOCUMENT } from '@angular/common';
import {
Component,
computed,
DestroyRef,
ElementRef,
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatRippleModule } from '@angular/material/core';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistActions,
selectActivePlaylistId,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { filter } from 'rxjs';
import { PortalStatus, PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
type PlaylistRouteProvider = 'playlists' | 'xtreams' | 'stalker';
type PlaylistSectionProvider = Exclude<PlaylistRouteProvider, 'playlists'>;
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
interface PlaylistRouteContext {
inWorkspace: boolean;
isWorkspaceDashboard: boolean;
provider: PlaylistRouteProvider | null;
playlistId: string | null;
section: string | null;
}
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections';
const LAST_ACTIVE_PLAYLIST_STORAGE_KEY =
'playlist-switcher:last-active-playlist-id';
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,
};
interface PlaylistSectionMemory {
providers: Partial<Record<PlaylistSectionProvider, string>>;
playlists: Record<
string,
{
provider: PlaylistSectionProvider;
section: string;
updatedAt: number;
}
>;
}
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;
@Component({
selector: 'app-playlist-switcher',
templateUrl: './playlist-switcher.component.html',
styleUrls: ['./playlist-switcher.component.scss'],
imports: [
DatePipe,
FormsModule,
MatIcon,
MatIconButton,
MatInputModule,
MatMenuModule,
MatRippleModule,
TranslatePipe,
],
})
export class PlaylistSwitcherComponent {
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly portalStatusService = inject(PortalStatusService);
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
/** Current playlist title to display */
readonly currentTitle = input.required<string>();
/** Subtitle to display (e.g., "123 Channels" or "Xtream Code") */
readonly subtitle = input<string>('');
/** Emitted when a different playlist is selected */
readonly playlistSelected = output<string>();
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');
/** Signal for tracking menu open state */
readonly isMenuOpen = signal(false);
/** Search query for filtering playlists */
readonly searchQuery = signal(this.readPersistedSearchQuery());
readonly playlistTypeFilters = signal(this.readPersistedTypeFilters());
/** All playlists from store */
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
/** Filtered playlists based on search query, sorted by importDate (newest first) */
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(
(p) =>
p.title?.toLowerCase().includes(query) ||
p.filename?.toLowerCase().includes(query)
)
: filteredByType;
return [...filtered].sort(
(a, b) =>
new Date(b.importDate ?? 0).getTime() -
new Date(a.importDate ?? 0).getTime()
);
});
/** Current active playlist ID from route */
readonly routePlaylistId = signal<string | null>(null);
readonly selectedPlaylistId = this.store.selectSignal(
selectActivePlaylistId
);
readonly activePlaylistId = computed(() => {
return this.routePlaylistId() ?? this.selectedPlaylistId() ?? null;
});
readonly activePlaylist = computed(() => {
const playlistId = this.activePlaylistId();
if (!playlistId) {
return null;
}
return (
this.playlists().find((playlist) => playlist._id === playlistId) ??
null
);
});
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'
);
});
/** Portal statuses for Xtream playlists */
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
constructor() {
// Extract playlist ID from current route
this.updateActivePlaylistFromRoute(this.router.url);
this.rememberSectionFromRoute(this.router.url);
effect(() => {
if (!this.allPlaylistsLoaded()) {
return;
}
const playlistIds = this.playlists().map(
(playlist) => playlist._id
);
this.removeDeletedPlaylistsFromSectionMemory(playlistIds);
});
effect(() => {
const allPlaylistsLoaded = this.allPlaylistsLoaded();
const playlistIds = this.playlists().map(
(playlist) => playlist._id
);
const playlistIdSet = new Set(playlistIds);
const selectedId = this.selectedPlaylistId();
const routeId = this.routePlaylistId();
const storedId = this.readPersistedActivePlaylistId();
// Before playlists are loaded, avoid stale cleanups.
// But still keep storage in sync with explicit runtime context.
if (!allPlaylistsLoaded) {
if (routeId) {
this.writePersistedActivePlaylistId(routeId);
return;
}
if (selectedId) {
this.writePersistedActivePlaylistId(selectedId);
}
return;
}
// If route provides playlist context, keep storage in sync with URL state.
if (routeId && playlistIdSet.has(routeId)) {
this.writePersistedActivePlaylistId(routeId);
return;
}
// Keep storage up-to-date with selected playlist state.
if (selectedId && playlistIdSet.has(selectedId)) {
this.writePersistedActivePlaylistId(selectedId);
return;
}
// On routes without playlist in URL (for example dashboard),
// restore last known active playlist when store has none.
if (
!routeId &&
!selectedId &&
storedId &&
playlistIdSet.has(storedId)
) {
this.store.dispatch(
PlaylistActions.setActivePlaylist({ playlistId: storedId })
);
return;
}
// Remove stale value if playlist no longer exists.
if (storedId && !playlistIdSet.has(storedId)) {
this.clearPersistedActivePlaylistId();
}
});
// Listen for route changes
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntilDestroyed()
)
.subscribe((event: NavigationEnd) => {
this.updateActivePlaylistFromRoute(event.urlAfterRedirects);
this.rememberSectionFromRoute(event.urlAfterRedirects);
});
this.destroyRef.onDestroy(() => {
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
});
}
private updateActivePlaylistFromRoute(url: string) {
// Match routes like /playlists/:id, /xtreams/:id, /stalker/:id
const match = url.match(/\/(playlists|xtreams|stalker)\/([^/?]+)/);
if (match) {
this.routePlaylistId.set(match[2]);
} else {
this.routePlaylistId.set(null);
}
}
private rememberSectionFromRoute(url: string): void {
const routeContext = this.getRouteContext(url);
if (
!routeContext.provider ||
routeContext.provider === 'playlists' ||
!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 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((r) => [r.id, r.status]));
this.portalStatuses.set(statusMap);
}
onMenuOpened() {
this.isMenuOpen.set(true);
this.syncMenuOverlayWidthToTrigger();
this.focusSearchField();
this.checkPortalStatuses(this.playlists());
}
onMenuClosed() {
this.isMenuOpen.set(false);
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
}
setSearchQuery(value: string) {
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];
}
private syncMenuOverlayWidthToTrigger() {
const triggerWidth = Math.round(
this.triggerElement().nativeElement.getBoundingClientRect().width
);
this.document.documentElement.style.setProperty(
'--playlist-switcher-overlay-width',
`${triggerWidth}px`
);
}
private clearMenuOverlayWidth() {
this.document.documentElement.style.removeProperty(
'--playlist-switcher-overlay-width'
);
}
private focusSearchField() {
this.clearSearchFocusTimeout();
this.focusSearchTimeoutId = setTimeout(() => {
const input = this.searchInput()?.nativeElement;
if (!input) {
return;
}
input.focus();
input.select();
this.focusSearchTimeoutId = null;
}, 0);
}
private clearSearchFocusTimeout() {
if (this.focusSearchTimeoutId !== null) {
clearTimeout(this.focusSearchTimeoutId);
this.focusSearchTimeoutId = null;
}
}
selectPlaylist(playlist: PlaylistMeta) {
const routeContext = this.getRouteContext(this.router.url);
const provider = this.getProviderForPlaylist(playlist);
this.setActivePlaylist(playlist._id);
const targetCommands = this.getTargetCommands(
provider,
playlist._id,
routeContext
);
if (targetCommands) {
this.router.navigate(targetCommands);
}
this.playlistSelected.emit(playlist._id);
}
private setActivePlaylist(playlistId: string): void {
this.store.dispatch(PlaylistActions.setActivePlaylist({ playlistId }));
}
private isWorkspaceRoute(url: string): boolean {
return /^\/workspace(?:\/|$)/.test(url);
}
private getProviderForPlaylist(
playlist: PlaylistMeta
): PlaylistRouteProvider {
if (playlist.serverUrl) {
return 'xtreams';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'playlists';
}
private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType {
if (playlist.macAddress) {
return 'stalker';
}
if (playlist.serverUrl) {
return 'xtream';
}
return 'm3u';
}
private getRouteContext(url: string): PlaylistRouteContext {
const inWorkspace = this.isWorkspaceRoute(url);
const isWorkspaceDashboard =
/^\/workspace(?:\/dashboard)?(?:\/)?(?:\?.*)?$/.test(url);
const match = url.match(
/^\/(?:workspace\/)?(playlists|xtreams|stalker)\/([^/?]+)(?:\/([^/?]+))?/
);
return {
inWorkspace,
isWorkspaceDashboard,
provider: (match?.[1] as PlaylistRouteProvider | undefined) ?? null,
playlistId: match?.[2] ?? null,
section: match?.[3] ?? null,
};
}
private getTargetCommands(
provider: PlaylistRouteProvider,
playlistId: string,
routeContext: PlaylistRouteContext
): string[] | null {
const prefix = routeContext.inWorkspace ? ['workspace'] : [];
// In workspace routes without explicit provider context (for example
// dashboard and sources), selecting a source should only change active
// context and must not trigger navigation.
if (routeContext.inWorkspace && !routeContext.provider) {
return null;
}
if (provider === 'playlists') {
return [...prefix, 'playlists', playlistId];
}
if (!this.supportsSectionNavigation(provider)) {
return [...prefix, provider, playlistId];
}
const section = this.resolveTargetSection(
provider,
routeContext.section,
playlistId
);
return [...prefix, provider, playlistId, section];
}
private resolveTargetSection(
provider: PlaylistSectionProvider,
currentSection: string | null,
targetPlaylistId: string
): string {
const fromCurrent = this.normalizeSectionForProvider(
currentSection,
provider
);
if (fromCurrent) {
return fromCurrent;
}
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;
}
return 'vod';
}
private supportsSectionNavigation(
provider: PlaylistSectionProvider
): boolean {
if (provider === 'xtreams') {
return Boolean(window.electron);
}
return true;
}
private normalizeSectionForProvider(
section: string | null | undefined,
provider: PlaylistSectionProvider
): 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 (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 {
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<PlaylistSectionProvider, 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['xtreams'] === 'string') {
providers.xtreams = candidate['xtreams'];
}
if (typeof candidate['stalker'] === 'string') {
providers.stalker = candidate['stalker'];
}
} else {
// Backward compatibility with previous format:
// { xtreams: "...", stalker: "..." }
const legacy = parsed as Record<string, unknown>;
if (typeof legacy['xtreams'] === 'string') {
providers.xtreams = legacy['xtreams'];
}
if (typeof legacy['stalker'] === 'string') {
providers.stalker = legacy['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 === '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 mode/storage quota).
}
}
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 mode/storage quota).
}
}
private clearPersistedActivePlaylistId(): void {
try {
localStorage.removeItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY);
} catch {
// Ignore storage write failures.
}
}
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 mode/storage quota).
}
}
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 (private mode/storage quota).
}
}
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,
});
}
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`;
}
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;
}
}
@@ -0,0 +1,87 @@
@if (type() === 'welcome') {
<div class="empty-state welcome-state">
<div class="illustration-container">
<!-- Monitor with WiFi signal -->
<svg class="illustration" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Background rounded rectangle -->
<rect x="40" y="40" width="432" height="432" rx="80" class="icon-background"/>
<!-- Monitor base -->
<rect x="134" y="387" width="244" height="18" rx="4" class="monitor-base"/>
<!-- Monitor stand -->
<rect x="238" y="347" width="36" height="40" rx="4" class="monitor-stand"/>
<!-- Monitor screen border -->
<rect x="88" y="102" width="336" height="245" rx="12" stroke="currentColor" stroke-width="12" fill="none" class="screen-border"/>
<!-- WiFi symbol -->
<!-- Bottom dot -->
<circle cx="256" cy="280" r="16" class="wifi-dot"/>
<!-- First arc (smallest) -->
<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"/>
<!-- Second arc (medium) -->
<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"/>
<!-- Third arc (largest) -->
<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>
<div class="content">
<h2 class="title">{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}</h2>
<p class="description">{{ 'HOME.PLAYLISTS.WELCOME_DESCRIPTION' | translate }}</p>
<div class="actions">
<button
mat-raised-button
class="primary-button"
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
>
<mat-icon>add_circle</mat-icon>
{{ 'HOME.PLAYLISTS.ADD_YOUR_FIRST' | translate }}
</button>
<app-add-playlist-menu
(playlistTypeSelected)="onAddPlaylist($event)"
/>
</div>
<div class="features">
<div class="feature-item">
<mat-icon class="feature-icon">folder_open</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_M3U' | translate }}</span>
</div>
<div class="feature-item">
<mat-icon class="feature-icon">cloud</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_XTREAM' | translate }}</span>
</div>
<div class="feature-item">
<mat-icon class="feature-icon">cast</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_STALKER' | translate }}</span>
</div>
</div>
</div>
</div>
} @else {
<div class="empty-state no-results-state">
<div class="illustration-container small">
<!-- Search/Empty themed illustration -->
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Magnifying glass -->
<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"/>
<!-- Empty box -->
<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>
}
@@ -0,0 +1,486 @@
:host {
display: block;
width: 100%;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
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;
}
}
.welcome-state {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
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;
}
.illustration-container {
margin-bottom: 32px;
position: relative;
}
.illustration {
width: 200px;
height: 200px;
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
color: #60a5fa;
.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;
}
}
}
.title {
font-size: 2rem;
letter-spacing: -0.02em;
}
.actions {
margin-bottom: 40px;
.primary-button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
font-size: 1rem;
padding: 12px 32px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
transition: all 0.3s 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);
}
}
}
.features {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
opacity: 0.6;
.feature-item {
display: flex;
align-items: center;
gap: 8px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.875rem;
padding: 8px 16px;
background: rgba(59, 130, 246, 0.08);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.15);
transition: all 0.3s ease;
.feature-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #60a5fa;
}
&:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
opacity: 1;
}
}
}
}
.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 {
stroke: currentColor;
opacity: 0.4;
}
.search-handle {
stroke: currentColor;
opacity: 0.4;
}
.empty-box {
stroke: currentColor;
}
.empty-line {
stroke: currentColor;
}
}
.description {
margin-bottom: 0;
font-size: 0.875rem;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes floatIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes gentleFloat {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 0.5;
transform: scale(1);
}
}
@keyframes gridMove {
0% {
transform: translate(0, 0) rotate(0deg);
}
100% {
transform: translate(40px, 40px) rotate(0.5deg);
}
}
@media (max-width: 480px) {
.welcome-state {
padding: 40px 20px;
.illustration {
width: 160px;
height: 160px;
}
.title {
font-size: 1.5rem;
}
.features {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
}
.no-results-state {
padding: 30px 20px;
.illustration {
width: 100px;
height: 100px;
}
}
}
@media (max-height: 700px) {
.welcome-state {
padding: 30px 24px;
.illustration-container { margin-bottom: 20px; }
.illustration { width: 150px; height: 150px; }
.title { font-size: 1.5rem; margin-bottom: 8px; }
.description { font-size: 0.875rem; margin-bottom: 20px; }
.actions { margin-bottom: 24px; }
.features { gap: 16px; }
.feature-item { padding: 6px 12px; font-size: 0.8125rem; }
}
.no-results-state {
padding: 24px 20px;
.illustration-container { margin-bottom: 16px; }
}
}
@media (max-height: 550px) {
.welcome-state {
padding: 20px 24px;
.illustration-container { margin-bottom: 16px; }
.illustration { width: 120px; height: 120px; }
.title { font-size: 1.25rem; margin-bottom: 6px; }
.description { font-size: 0.8125rem; margin-bottom: 16px; }
.actions { margin-bottom: 16px; }
.primary-button { padding: 10px 24px; font-size: 0.9rem; }
.features { gap: 12px; }
.feature-item { padding: 4px 10px; font-size: 0.75rem; }
.feature-icon { font-size: 16px; width: 16px; height: 16px; }
}
.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; }
}
}
@media (max-width: 480px) and (max-height: 700px) {
.welcome-state {
padding: 20px 16px;
.illustration {
width: 120px;
height: 120px;
}
.title {
font-size: 1.25rem;
}
}
}
@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;
}
}
.welcome-state {
// Light theme grid
&::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
);
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%);
}
.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;
}
}
.features {
.feature-item {
background: rgba(37, 99, 235, 0.06);
border-color: rgba(37, 99, 235, 0.12);
.feature-icon {
color: #2563eb;
}
&:hover {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.2);
}
}
}
}
.no-results-state {
.illustration {
opacity: 0.3;
.search-circle,
.search-handle,
.empty-box,
.empty-line {
stroke: #64748b;
}
}
}
}
@@ -0,0 +1,24 @@
import { Component, input, output, viewChild } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { AddPlaylistMenuComponent, PlaylistType } from '../../add-playlist-menu/add-playlist-menu.component';
export type EmptyStateType = 'welcome' | 'no-results';
@Component({
selector: 'app-empty-state',
templateUrl: './empty-state.component.html',
styleUrls: ['./empty-state.component.scss'],
imports: [AddPlaylistMenuComponent, MatButtonModule, MatIcon, MatMenuModule, TranslatePipe],
})
export class EmptyStateComponent {
readonly addPlaylistMenuComponent = viewChild.required(AddPlaylistMenuComponent);
readonly type = input.required<EmptyStateType>();
readonly addPlaylistClicked = output<PlaylistType>();
onAddPlaylist(playlistType: PlaylistType): void {
this.addPlaylistClicked.emit(playlistType);
}
}
@@ -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,250 @@
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;
}
`,
],
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,162 @@
<mat-list-item
cdkDrag
[cdkDragDisabled]="!isDraggable()"
cdkDragPreviewContainer="parent"
[class.selected]="isSelected()"
(click)="playlistClicked.emit(item._id)"
>
@if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon" matListItemIcon
>drag_indicator</mat-icon
>
}
@if (item.url) {
<div class="icon-container" matListItemIcon>
<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" matListItemIcon>
<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" matListItemIcon>
<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
matListItemIcon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.STALKER_PORTAL' | translate"
>dashboard</mat-icon
>
}
<div matListItemTitle>
{{ item.title || item.filename }}
</div>
<div matListItemLine 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 }}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }}
}
@if (item.updateState === 2) {
<mat-icon
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' | translate
"
[inline]="true"
>warning</mat-icon
>
}
} @else {
@if (item.macAddress) {
Stalker Portal
} @else {
Xtream Code Playlist
}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date }}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }}
}
}
</div>
@if (showActions()) {
<span matListItemMeta class="action-buttons">
@if (item.url || item.filePath) {
<button
mat-icon-button
class="refresh-btn"
color="accent"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
>
<mat-icon>sync</mat-icon>
</button>
}
@if (item.serverUrl && isElectron) {
<button
mat-icon-button
class="refresh-btn"
color="accent"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
>
<mat-icon>sync</mat-icon>
</button>
}
<button
mat-icon-button
color="accent"
class="edit-btn"
(click)="
$event.stopPropagation(); editPlaylistClicked.emit(item)
"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="accent"
class="delete-btn"
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
>
<mat-icon>delete</mat-icon>
</button>
</span>
}
</mat-list-item>
@@ -0,0 +1,215 @@
mat-list-item {
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 12px;
margin: 2px 4px;
border: 1px solid transparent;
&:hover {
background-color: rgba(255, 255, 255, 0.03) !important;
}
&.selected {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
) !important;
border-color: var(--app-selection-border) !important;
color: var(--app-selection-color) !important;
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 !important;
}
.status-dot {
border-color: color-mix(
in srgb,
var(--app-selection-surface-strong) 85%,
transparent
);
}
}
}
// Light theme hover
@media (prefers-color-scheme: light) {
mat-list-item:hover {
background-color: rgba(0, 0, 0, 0.04) !important;
}
}
.mat-list-base {
padding-top: 0 !important;
}
.meta {
font-size: 12px !important;
color: #666;
margin-top: 2px !important;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 12px;
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 {
cursor: move;
margin-right: 0;
opacity: 0.5;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
::ng-deep {
@media only screen and (max-width: 480px) {
.main-container {
.favorites-icon,
.upload-type-icon,
.refresh-btn,
.edit-btn,
.delete-btn {
display: none;
}
}
.mdc-list-item__content {
padding-left: 20px;
}
}
}
.action-buttons {
align-self: center !important;
button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(255, 255, 255, 0.06) !important;
transform: scale(1.05);
mat-icon {
opacity: 1;
}
}
mat-icon {
opacity: 0.85;
transition: opacity 0.3s ease;
}
}
}
// Light theme action buttons
@media (prefers-color-scheme: light) {
.action-buttons button:hover {
background-color: rgba(0, 0, 0, 0.04) !important;
}
}
.icon-container {
position: relative;
display: inline-flex;
.upload-type-icon {
opacity: 0.75;
transition: opacity 0.3s ease;
}
mat-list-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(--background-color, #131316);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.3s 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: #666666;
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #4caf50;
background-color: var(--background-color, #131316);
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.3s ease;
mat-list-item:hover & {
color: #66bb6a;
transform: rotate(90deg);
}
}
// Light theme auto-refresh indicator
@media (prefers-color-scheme: light) {
.auto-refresh-indicator {
background-color: #ffffff;
}
}
@media only screen and (max-width: 480px) {
.upload-type-icon,
.icon-container {
display: none;
}
}
@@ -0,0 +1,43 @@
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 { DataService } 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: [DataService],
}).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();
});
});
@@ -0,0 +1,73 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
import { Component, Input, OnInit, inject, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
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,
MatListModule,
MatTooltip,
TranslatePipe,
],
})
export class PlaylistItemComponent implements OnInit {
@Input() item!: PlaylistMeta;
readonly showActions = input(true);
readonly isDraggable = input(false);
readonly isSelected = input(false);
readonly editPlaylistClicked = output<PlaylistMeta>();
readonly playlistClicked = output<string>();
readonly refreshClicked = output<PlaylistMeta>();
readonly removeClicked = output<string>();
portalStatus: PortalStatus = 'unavailable';
private readonly portalStatusService = inject(PortalStatusService);
readonly isElectron = !!window.electron;
async ngOnInit() {
await this.checkPortalStatus();
}
private async checkPortalStatus() {
try {
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
);
}
} catch (error) {
console.error('Error checking portal status:', error);
this.portalStatus = 'unavailable';
}
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.portalStatus);
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.portalStatus);
}
}
@@ -0,0 +1,63 @@
@if (allPlaylistsLoaded()) {
@defer {
@if (playlistsData$ | async; as data) {
@if (data.playlists.length === 0) {
@if (data.totalCount === 0) {
<!-- Welcome state: no playlists at all -->
<app-empty-state
[type]="'welcome'"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- No results state: search/filter returned nothing -->
<app-empty-state [type]="'no-results'" />
}
} @else {
<mat-nav-list
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()"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
/>
}
</mat-nav-list>
}
}
} @placeholder {
<div id="skeleton-container">
@for (a of ghostElements; track $index) {
<div class="skeleton-item">
<ngx-skeleton-loader count="1" appearance="circle" />
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
/>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
/>
</div>
<div class="actions">
<ngx-skeleton-loader count="2" appearance="circle" />
</div>
</div>
}
</div>
}
}
@@ -0,0 +1,57 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
align-items: center;
}
.mat-list-base {
padding-top: 0;
}
.playlists-list {
padding: 0;
width: 100%;
overflow-x: hidden;
}
.meta {
font-size: 12px !important;
color: #666;
margin-top: 2px !important;
}
// Light theme
@media (prefers-color-scheme: light) {
.meta {
color: #757575;
}
}
#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,130 @@
/* import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { initialPlaylistMetaState } from 'm3u-state';
import { MockComponent, MockModule, MockProvider } from 'ng-mocks';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { Observable } from 'rxjs';
import { DataService } from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import { ElectronServiceStub } from '../../../../../../apps/web/src/app/services/electron.service.stub';
import { DialogService } from '../confirm-dialog/dialog.service';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
import { RecentPlaylistsComponent } from './recent-playlists.component';
describe('RecentPlaylistsComponent', () => {
let component: RecentPlaylistsComponent;
let fixture: ComponentFixture<RecentPlaylistsComponent>;
let electronService: DataService;
let dialog: MatDialog;
let dialogService: DialogService;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
RecentPlaylistsComponent,
MockComponent(PlaylistItemComponent),
MockModule(MatDialogModule),
MockModule(MatDividerModule),
MockModule(MatListModule),
MockModule(MatIconModule),
MockModule(MatInputModule),
MockModule(MatTooltipModule),
MockComponent(NgxSkeletonLoaderComponent),
TranslateModule.forRoot(),
],
providers: [
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(DialogService),
MatSnackBar,
provideMockStore(),
provideMockActions(actions$),
MockProvider(TranslateService),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(RecentPlaylistsComponent);
component = fixture.componentInstance;
dialog = TestBed.inject(MatDialog);
electronService = TestBed.inject(DataService);
dialogService = TestBed.inject(DialogService);
mockStore = TestBed.inject(MockStore);
mockStore.setState({
playlistState: { playlists: initialPlaylistMetaState },
});
console.error = jest.fn();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should open the info dialog', () => {
jest.spyOn(dialog, 'open');
component.openInfoDialog({} as PlaylistMeta);
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('should send an ipc event after drop event', () => {
const event = {
previousIndex: 0,
currentIndex: 1,
item: undefined,
container: undefined,
previousContainer: undefined,
isPointerOverContainer: true,
distance: { x: 0, y: 0 },
dropPoint: { x: 0, y: 0 },
} as any;
jest.spyOn(mockStore, 'dispatch');
component.drop(event, []);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
it('should open the confirmation dialog on remove icon click', () => {
const playlistId = '12345';
jest.spyOn(dialogService, 'openConfirmDialog');
component.removeClicked(playlistId);
expect(dialogService.openConfirmDialog).toHaveBeenCalledTimes(1);
});
it('should send an event to the main process to refresh a playlist', () => {
const playlistMeta: PlaylistMeta = {
id: 'iptv1',
title: 'iptv',
filePath: '/home/user/lists/iptv.m3u',
} as unknown as PlaylistMeta;
jest.spyOn(electronService, 'sendIpcEvent');
component.refreshPlaylist(playlistMeta);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_UPDATE,
{
id: playlistMeta._id,
filePath: playlistMeta.filePath,
title: playlistMeta.title,
}
);
});
it('should send an event to the main process to get a playlist', () => {
const playlistId = '6789';
jest.spyOn(component.playlistClicked, 'emit');
component.getPlaylist({ _id: playlistId } as unknown as PlaylistMeta);
expect(component.playlistClicked.emit).toHaveBeenCalledTimes(1);
});
});
*/
@@ -0,0 +1,304 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import { AsyncPipe } from '@angular/common';
import { Component, effect, inject, input, output } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
PlaylistActions,
selectActivePlaylistId,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DialogService } from 'components';
import { DatabaseService, DataService, SortBy, SortService } from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
@Component({
selector: 'app-recent-playlists',
templateUrl: './recent-playlists.component.html',
styleUrls: ['./recent-playlists.component.scss'],
imports: [
AsyncPipe,
DragDropModule,
EmptyStateComponent,
MatInputModule,
MatListModule,
NgxSkeletonLoaderComponent,
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 router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
readonly sidebarMode = input(false);
readonly searchQueryInput = input<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<PlaylistType>();
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId);
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
);
readonly isCustomSortActive = () =>
this.currentSortOptions().by === SortBy.CUSTOM;
readonly searchQuery = new BehaviorSubject<string>('');
readonly ghostElements = new Array(10);
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,
};
})
);
/**
* 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(playlistType: PlaylistType) {
this.addPlaylistClicked.emit(playlistType);
}
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(playlistId: string): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: () => {
this.removePlaylist(playlistId);
},
});
}
/**
* Removes the provided playlist from the database
* @param playlistId playlist id to remove
*/
async removePlaylist(playlistId: string) {
const deleted = await this.databaseService.deletePlaylist(playlistId);
if (deleted) {
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
undefined,
{
duration: 2000,
}
);
}
}
/**
* 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 (item.serverUrl) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(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) {
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 () => {
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 [
{
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
},
] = await Promise.all([
this.databaseService.deleteXtreamPlaylistContent(
item._id
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]);
// Update the timestamp in NgRx / IndexedDB
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
// Persist user data so it can be restored after re-import
const restoreKey = `xtream-restore-${item._id}`;
localStorage.setItem(
restoreKey,
JSON.stringify({
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
})
);
// Navigate to the playlist to trigger re-import
this.router.navigate(['/workspace', 'xtreams', item._id]);
} catch (error) {
console.error('Error refreshing Xtream playlist:', error);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
),
undefined,
{ duration: 3000 }
);
}
},
});
}
}