mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-17 06:36:15 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
aa739f43e0
commit
7ac2fd66b8
175 files changed
+3118
-1076
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
+87
@@ -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>
|
||||
}
|
||||
+486
@@ -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);
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
<form
|
||||
[formGroup]="playlistDetails"
|
||||
(ngSubmit)="saveChanges(playlistDetails.value)"
|
||||
>
|
||||
<h2 mat-dialog-title>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
|
||||
</h2>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="title" matInput />
|
||||
</mat-form-field>
|
||||
@if (playlist.portalUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="portalUrl" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.macAddress) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="macAddress" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.portalUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSerialNumber" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerDeviceId2" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature1" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="stalkerSignature2" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.serverUrl) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="serverUrl" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.username) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="username" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.password) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="password" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.url) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="url" />
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
(click)="copyUrl()"
|
||||
[matTooltip]="
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filename) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="filename" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="importDate" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.count) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
|
||||
}}</mat-label>
|
||||
<input matInput formControlName="count" />
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop) {
|
||||
<mat-form-field class="w-full mb-10">
|
||||
<mat-label>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
|
||||
}}</mat-label>
|
||||
<input formControlName="userAgent" matInput />
|
||||
<mat-hint>{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate
|
||||
}}</mat-hint>
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (playlist.filePath) {
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
|
||||
</mat-label>
|
||||
<input formControlName="filePath" matInput />
|
||||
@if (playlist.updateState === 2) {
|
||||
<mat-hint>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
|
||||
| translate
|
||||
}}
|
||||
</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
}
|
||||
@if (isDesktop && (playlist.url || playlist.filePath)) {
|
||||
<mat-checkbox class="w-full" formControlName="autoRefresh">
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
|
||||
</mat-checkbox>
|
||||
<p>
|
||||
{{
|
||||
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
@if (!playlist.serverUrl && !playlist.portalUrl) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
type="button"
|
||||
(click)="exportPlaylist()"
|
||||
>
|
||||
<mat-icon>download</mat-icon>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }}
|
||||
</button>
|
||||
}
|
||||
<span class="spacer"></span>
|
||||
<button mat-button mat-dialog-close>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
mat-dialog-close
|
||||
color="accent"
|
||||
type="submit"
|
||||
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
|
||||
>
|
||||
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</form>
|
||||
+250
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+162
@@ -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>
|
||||
+215
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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();
|
||||
});
|
||||
});
|
||||
+73
@@ -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 }
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user