mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
refactor: code style improvements
This commit is contained in:
1 parent
ae5ee88495
commit
22c1e5462c
14 files changed
+759
-255
No files matched your search
@@ -1,4 +1,8 @@
|
||||
<mat-drawer-container class="main-container" autosize>
|
||||
<mat-drawer-container
|
||||
class="main-container"
|
||||
[class.main-container--workspace]="isWorkspaceLayout"
|
||||
autosize
|
||||
>
|
||||
<!-- sidebar content -->
|
||||
<mat-drawer
|
||||
#drawer
|
||||
@@ -14,7 +18,10 @@
|
||||
handlePosition="right"
|
||||
>
|
||||
@defer {
|
||||
<app-sidebar [channels]="(channels$ | async)!" />
|
||||
<app-sidebar
|
||||
[channels]="(channels$ | async)!"
|
||||
[showPlaylistHeader]="!isWorkspaceLayout"
|
||||
/>
|
||||
} @loading {
|
||||
<p>Loading...</p>
|
||||
} @placeholder {
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -7,6 +14,23 @@
|
||||
overflow: hidden; // Prevent horizontal scrollbar from resize handle
|
||||
}
|
||||
|
||||
.main-container.main-container--workspace {
|
||||
position: relative;
|
||||
top: auto;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main-container.main-container--workspace .mat-drawer-content,
|
||||
.main-container.main-container--workspace .drawer-content {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mat-drawer-content {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
|
||||
@@ -108,6 +108,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
|
||||
readonly isDesktop = !!window['electron'];
|
||||
readonly isWorkspaceLayout =
|
||||
this.activatedRoute.snapshot.data['layout'] === 'workspace';
|
||||
|
||||
sidebarView: SidebarView = 'CHANNELS';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<form [formGroup]="form">
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="title">{{
|
||||
'HOME.XTREAM_PLAYLIST.TITLE' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="title" formControlName="title" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width" subscriptSizing="dynamic">
|
||||
<mat-form-field class="w-full" subscriptSizing="dynamic">
|
||||
<mat-label for="serverUrl">{{
|
||||
'HOME.XTREAM_PLAYLIST.SERVER_URL' | translate
|
||||
}}</mat-label>
|
||||
@@ -21,13 +21,13 @@
|
||||
}}</mat-hint>
|
||||
<mat-error>{{ 'SETTINGS.EPG_URL_ERROR' | translate }}</mat-error>
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="username">{{
|
||||
'HOME.XTREAM_PLAYLIST.USERNAME' | translate
|
||||
}}</mat-label>
|
||||
<input matInput type="text" id="username" formControlName="username" />
|
||||
</mat-form-field>
|
||||
<mat-form-field class="full-width">
|
||||
<mat-form-field class="w-full">
|
||||
<mat-label for="password">{{
|
||||
'HOME.XTREAM_PLAYLIST.PASSWORD' | translate
|
||||
}}</mat-label>
|
||||
@@ -53,7 +53,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (connectionStatus){
|
||||
@if (connectionStatus) {
|
||||
<div class="connection-status">
|
||||
<mat-icon [class]="getStatusClass()">
|
||||
{{ getStatusIcon() }}
|
||||
|
||||
+2
-5
@@ -1,11 +1,10 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { PortalStore } from '../../../xtream/portal.store';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -68,13 +67,11 @@ import { PortalStore } from '../../../xtream/portal.store';
|
||||
],
|
||||
})
|
||||
export class ExternalPlayerInfoDialogComponent {
|
||||
readonly portalStore = inject(PortalStore);
|
||||
|
||||
openUrl(url: string) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
setVisibility(value: boolean) {
|
||||
this.portalStore.setHideExternalInfoDialog(value);
|
||||
localStorage.setItem('hideExternalInfoDialog', value.toString());
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
// ─── Card base ───────────────────────────────────────────────────────────────
|
||||
mat-card {
|
||||
overflow: hidden;
|
||||
transition: all 0.9s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
mat-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow:
|
||||
0 14px 28px rgb(0 0 0 / 25%),
|
||||
0 10px 10px rgb(0 0 0 / 22%);
|
||||
0 8px 24px rgba(0, 0, 0, 0.35),
|
||||
0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
// ─── Grid layout ─────────────────────────────────────────────────────────────
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 30px;
|
||||
justify-content: center;
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
width: 170px;
|
||||
width: 100%;
|
||||
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
@@ -28,59 +30,81 @@ mat-card:hover {
|
||||
.card-thumbnail-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 2 / 3; // maintain poster ratio
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
}
|
||||
|
||||
.stream-icon {
|
||||
width: 100%;
|
||||
position: relative !important;
|
||||
border-radius: 12px;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
display: block;
|
||||
background-color: #f0f0f0;
|
||||
// No hardcoded light bg — uses parent container bg
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stream-icon-placeholder {
|
||||
width: 100%;
|
||||
background: #999;
|
||||
max-height: 300px;
|
||||
min-height: 255px;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
// Use graphite surface token, not hardcoded grey
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
|
||||
border-radius: 10px;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
word-break: break-all;
|
||||
margin-top: 8px;
|
||||
padding: 0 2px;
|
||||
word-break: break-word;
|
||||
text-align: center;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rating badge ─────────────────────────────────────────────────────────────
|
||||
.rating {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background-color: #3bb33b;
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
border-radius: 3px;
|
||||
// Use a semi-transparent dark pill, not hardcoded green
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
color: #4ade80; // green-400, readable across themes
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
padding: 2px 5px;
|
||||
gap: 2px;
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
font-size: 13px;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-b
|
||||
|
||||
@Component({
|
||||
selector: 'app-grid-list',
|
||||
template: `<div class="grid">
|
||||
template: `<div
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
<div class="loading-overlay">
|
||||
<div class="flex justify-center items-center p-8">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
@@ -82,7 +84,7 @@ import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-b
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (items()?.length > 0) {
|
||||
@if (showPaginator() && items()?.length > 0) {
|
||||
<mat-paginator
|
||||
[pageIndex]="pageIndex()"
|
||||
[length]="totalPages() * limit()"
|
||||
@@ -108,6 +110,7 @@ import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-b
|
||||
export class GridListComponent {
|
||||
readonly items = input<any[]>();
|
||||
readonly isLoading = input<boolean>();
|
||||
readonly showPaginator = input(true);
|
||||
readonly itemClicked = output<any>();
|
||||
readonly pageChange = output<any>();
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ export interface XtreamCategory {
|
||||
category_id: string;
|
||||
category_name: string;
|
||||
parent_id: number;
|
||||
count?: number;
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
<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">{{ currentTitle() }}</div>
|
||||
<div class="name">{{ displayTitle() }}</div>
|
||||
<div class="channels-count">{{ subtitle() }}</div>
|
||||
</div>
|
||||
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
|
||||
@@ -18,7 +23,10 @@
|
||||
|
||||
<mat-menu
|
||||
#playlistMenu="matMenu"
|
||||
class="playlist-switcher-menu"
|
||||
panelClass="playlist-switcher-menu"
|
||||
hasBackdrop
|
||||
backdropClass="playlist-switcher-menu-backdrop"
|
||||
overlapTrigger="false"
|
||||
xPosition="after"
|
||||
yPosition="below"
|
||||
>
|
||||
@@ -30,6 +38,7 @@
|
||||
<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"
|
||||
|
||||
@@ -7,14 +7,10 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
app-region: no-drag;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -22,24 +18,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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: 1em;
|
||||
font-weight: 500;
|
||||
font-size: 0.95em;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.channels-count {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.6;
|
||||
line-height: 1.4;
|
||||
font-size: 0.74em;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +63,7 @@
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.7;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-left: auto;
|
||||
|
||||
@@ -56,23 +72,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme
|
||||
@media (prefers-color-scheme: light) {
|
||||
.playlist-switcher-trigger:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
// Menu styles (global)
|
||||
::ng-deep .playlist-switcher-menu {
|
||||
min-width: 320px !important;
|
||||
max-width: 320px !important;
|
||||
border-radius: 12px !important;
|
||||
::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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +117,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.menu-header {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -110,89 +127,53 @@
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: 0.85em;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.search-container {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
.search-container:focus-within {
|
||||
border-color: var(--mat-sys-primary);
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
|
||||
// Custom scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.menu-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-item {
|
||||
@@ -200,53 +181,24 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 12px;
|
||||
padding: 6px 12px;
|
||||
min-height: 52px;
|
||||
transition: all 0.15s ease;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
.playlist-item.selected {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
|
||||
.playlist-title {
|
||||
color: #a78bfa;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.selected) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
.playlist-title,
|
||||
.playlist-meta {
|
||||
color: #a78bfa;
|
||||
}
|
||||
}
|
||||
|
||||
/* .selected-indicator {
|
||||
color: #8b5cf6;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selected-indicator-placeholder {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
} */
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.playlist-item {
|
||||
&.selected {
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
|
||||
.playlist-title {
|
||||
color: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.selected) {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
.playlist-item:hover:not(.selected) {
|
||||
background: var(--mat-sys-surface-container);
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
@@ -261,17 +213,11 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.playlist-icon-container {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-type-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.8;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -279,10 +225,9 @@
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
border: 2px solid var(--mat-menu-container-color, #2a2a2e);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.status-active {
|
||||
@@ -311,25 +256,12 @@
|
||||
font-size: 12px !important;
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
color: #4caf50;
|
||||
background-color: var(--mat-menu-container-color, #2a2a2e);
|
||||
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;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.auto-refresh-indicator,
|
||||
.status-dot {
|
||||
border-color: var(--mat-menu-container-color, #fff);
|
||||
background-color: var(--mat-menu-container-color, #fff);
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
background-color: var(--mat-menu-container-color, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-details {
|
||||
@@ -337,21 +269,23 @@
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
font-size: 0.92em;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 0.75em;
|
||||
opacity: 0.55;
|
||||
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;
|
||||
@@ -360,7 +294,7 @@
|
||||
white-space: nowrap;
|
||||
|
||||
.separator {
|
||||
opacity: 0.4;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +305,7 @@
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
gap: 12px;
|
||||
opacity: 0.5;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
@@ -384,7 +318,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media only screen and (max-width: 480px) {
|
||||
.playlist-info {
|
||||
max-width: 140px;
|
||||
@@ -394,10 +327,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .playlist-switcher-menu {
|
||||
min-width: 280px !important;
|
||||
}
|
||||
|
||||
.playlist-icon-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { DatePipe, DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
@@ -18,11 +21,60 @@ 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 { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
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'>;
|
||||
|
||||
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';
|
||||
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',
|
||||
@@ -39,9 +91,12 @@ import { PlaylistMeta } from 'shared-interfaces';
|
||||
],
|
||||
})
|
||||
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>();
|
||||
@@ -52,6 +107,10 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
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);
|
||||
@@ -61,6 +120,7 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
/** 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(() => {
|
||||
@@ -82,7 +142,33 @@ export class PlaylistSwitcherComponent {
|
||||
});
|
||||
|
||||
/** Current active playlist ID from route */
|
||||
readonly activePlaylistId = signal<string | null>(null);
|
||||
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());
|
||||
@@ -90,6 +176,61 @@ export class PlaylistSwitcherComponent {
|
||||
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
|
||||
@@ -99,19 +240,55 @@ export class PlaylistSwitcherComponent {
|
||||
)
|
||||
.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.activePlaylistId.set(match[2]);
|
||||
this.routePlaylistId.set(match[2]);
|
||||
} else {
|
||||
this.activePlaylistId.set(null);
|
||||
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(
|
||||
@@ -142,25 +319,358 @@ export class PlaylistSwitcherComponent {
|
||||
|
||||
onMenuOpened() {
|
||||
this.isMenuOpen.set(true);
|
||||
this.syncMenuOverlayWidthToTrigger();
|
||||
this.focusSearchField();
|
||||
this.checkPortalStatuses(this.playlists());
|
||||
}
|
||||
|
||||
onMenuClosed() {
|
||||
this.isMenuOpen.set(false);
|
||||
this.clearMenuOverlayWidth();
|
||||
this.clearSearchFocusTimeout();
|
||||
this.searchQuery.set('');
|
||||
}
|
||||
|
||||
selectPlaylist(playlist: PlaylistMeta) {
|
||||
if (playlist.serverUrl) {
|
||||
this.router.navigate(['xtreams', playlist._id]);
|
||||
} else if (playlist.macAddress) {
|
||||
this.router.navigate(['stalker', playlist._id]);
|
||||
} else {
|
||||
this.router.navigate(['playlists', playlist._id]);
|
||||
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 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 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';
|
||||
|
||||
@@ -57,10 +57,6 @@ body.resizing-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&--right {
|
||||
right: -6px;
|
||||
@@ -86,7 +82,6 @@ body.resizing-active {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<div class="current-playlist">
|
||||
<button
|
||||
mat-icon-button
|
||||
routerLink="/"
|
||||
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
|
||||
class="home-btn"
|
||||
>
|
||||
<mat-icon>home</mat-icon>
|
||||
</button>
|
||||
@if (showPlaylistHeader()) {
|
||||
<div class="current-playlist">
|
||||
<button
|
||||
mat-icon-button
|
||||
routerLink="/"
|
||||
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
|
||||
class="home-btn"
|
||||
>
|
||||
<mat-icon>home</mat-icon>
|
||||
</button>
|
||||
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="subtitle()"
|
||||
/>
|
||||
</div>
|
||||
<app-playlist-switcher
|
||||
class="switcher"
|
||||
[currentTitle]="playlistTitle()"
|
||||
[subtitle]="subtitle()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-channel-list-container [channelList]="channels()" />
|
||||
@@ -26,6 +26,7 @@ import { ChannelListContainerComponent } from './../../channel-list-container/ch
|
||||
})
|
||||
export class SidebarComponent {
|
||||
readonly channels = input<Channel[]>([]);
|
||||
readonly showPlaylistHeader = input(true);
|
||||
|
||||
private readonly store = inject(Store);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
Reference in new issue
Block a user