feat(workspace): implement workspace shell and sources filters panel

- Added WorkspaceShellComponent to manage the main workspace layout and navigation.
- Introduced WorkspaceSourcesFiltersPanelComponent for filtering and sorting playlists.
- Created WorkspaceSourcesComponent to display playlists with search functionality.
- Implemented HTML and SCSS for the new components to ensure proper styling and layout.
- Updated typings to include new database method for fetching global favorites.
This commit is contained in:
4gray committed 2026-02-24 19:11:04 +01:00
1 parent 349fbcea47
commit 27bcc9d059
20 files changed
+3282

No files matched your search

@@ -0,0 +1,161 @@
<section class="dashboard-page">
<!-- ─── Hero ─────────────────────────────────────────────────────────── -->
<header class="hero">
<div class="hero-content">
<div class="hero-text">
<p class="eyebrow">
<span class="eyebrow-dot"></span>
Workspace
</p>
<h1>Your IPTV sources,<br />one place.</h1>
<p class="description">
Jump back into your active source, switch providers
instantly, or explore what's new across your library.
</p>
<div class="hero-actions">
@if (activePlaylist(); as active) {
<a
mat-flat-button
[routerLink]="getPlaylistLink(active)"
>
<mat-icon>play_arrow</mat-icon>
Continue with {{ active.title }}
</a>
} @else {
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>library_books</mat-icon>
Open Sources
</a>
}
<a mat-stroked-button routerLink="/settings">
<mat-icon>settings</mat-icon>
Settings
</a>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<div class="hero-icon-ring">
<mat-icon>tv</mat-icon>
</div>
</div>
</div>
</header>
<!-- ─── Stats Row ─────────────────────────────────────────────────────── -->
<div class="stat-grid">
<article class="stat-card stat-card--total">
<div class="stat-card__icon">
<mat-icon>folder_open</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().total }}</span>
<span class="stat-card__label">Total Sources</span>
</div>
</article>
<article class="stat-card stat-card--xtream">
<div class="stat-card__icon">
<mat-icon>stream</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().xtream }}</span>
<span class="stat-card__label">Xtream</span>
</div>
</article>
<article class="stat-card stat-card--stalker">
<div class="stat-card__icon">
<mat-icon>router</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().stalker }}</span>
<span class="stat-card__label">Stalker</span>
</div>
</article>
<article class="stat-card stat-card--m3u">
<div class="stat-card__icon">
<mat-icon>playlist_play</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().m3u }}</span>
<span class="stat-card__label">M3U</span>
</div>
</article>
</div>
<!-- ─── Recent Sources ────────────────────────────────────────────────── -->
<section class="sources-section">
<div class="section-header">
<h2>Recent Sources</h2>
<a routerLink="/workspace/sources" class="section-link">
<mat-icon>arrow_forward</mat-icon>
View all
</a>
</div>
@if (recentPlaylists().length > 0) {
<div class="sources-grid">
@for (
playlist of recentPlaylists();
track trackByPlaylistId($index, playlist)
) {
<a
class="source-card"
[routerLink]="getPlaylistLink(playlist)"
>
<div class="source-card__header">
<span
class="provider-badge"
[class]="
'provider-badge--' +
getPlaylistProvider(playlist).toLowerCase()
"
>
{{ getPlaylistProvider(playlist) }}
</span>
@if (playlist.count) {
<span class="source-card__count">
{{ playlist.count | number }} ch
</span>
}
</div>
<h3
[attr.title]="
playlist.title ||
playlist.filename ||
'Untitled source'
"
>
{{
playlist.title ||
playlist.filename ||
'Untitled source'
}}
</h3>
<p class="source-card__date">
<mat-icon>schedule</mat-icon>
{{
formatTimestamp(
playlist.updateDate || playlist.importDate
)
}}
</p>
</a>
}
</div>
} @else {
<article class="empty-state">
<div class="empty-state__icon">
<mat-icon>library_add</mat-icon>
</div>
<h3>No sources yet</h3>
<p>Add a playlist in Library to start using the workspace.</p>
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>add</mat-icon>
Add Source
</a>
</article>
}
</section>
</section>
@@ -0,0 +1,439 @@
:host {
display: block;
min-height: 100%;
padding: 20px;
box-sizing: border-box;
background: var(--app-content-bg, var(--mat-sys-surface));
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.dashboard-page {
max-width: 1280px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
// ─── Hero ─────────────────────────────────────────────────────────────────────
.hero {
border-radius: 16px;
padding: 28px;
background: var(--app-widget-bg, var(--mat-sys-surface-container-high));
background-image: var(--app-accent-gradient);
border: none;
position: relative;
overflow: hidden;
}
.hero-content {
display: flex;
align-items: center;
gap: 24px;
}
.hero-text {
flex: 1;
}
.eyebrow {
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 7px;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
font-weight: 600;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
}
.eyebrow-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mat-sys-primary);
flex-shrink: 0;
}
h1 {
margin: 0 0 12px;
font-size: clamp(1.5rem, 2.2vw, 2rem);
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.description {
margin: 0;
max-width: 560px;
line-height: 1.55;
font-size: 0.9rem;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
}
.hero-actions {
margin-top: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
a {
text-decoration: none;
}
}
// Visual accent element on the right
.hero-visual {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero-icon-ring {
width: 80px;
height: 80px;
border-radius: 24px;
background: var(--mat-sys-primary-container);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--mat-sys-outline-variant);
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: var(--mat-sys-on-primary-container);
}
}
// ─── Stat Grid ────────────────────────────────────────────────────────────────
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stat-card {
border-radius: 14px;
padding: 16px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: none;
box-shadow: none;
display: flex;
align-items: center;
gap: 14px;
transition:
transform 0.15s ease,
background-color 0.15s ease;
overflow: hidden;
position: relative;
// Top accent line per type
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
}
&:hover {
transform: translateY(-2px);
background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high));
}
}
.stat-card--total::before {
background: var(--mat-sys-primary);
}
.stat-card--xtream::before {
background: var(--app-provider-xtream, #818cf8);
}
.stat-card--stalker::before {
background: var(--app-provider-stalker, #34d399);
}
.stat-card--m3u::before {
background: var(--app-provider-m3u, #fbbf24);
}
.stat-card__icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface-container-high);
flex-shrink: 0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
}
}
.stat-card--xtream .stat-card__icon mat-icon {
color: var(--app-provider-xtream, #818cf8);
}
.stat-card--stalker .stat-card__icon mat-icon {
color: var(--app-provider-stalker, #34d399);
}
.stat-card--m3u .stat-card__icon mat-icon {
color: var(--app-provider-m3u, #fbbf24);
}
.stat-card__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat-card__value {
font-size: 1.6rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.stat-card__label {
font-size: 0.8rem;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
font-weight: 500;
}
// ─── Sources Section ──────────────────────────────────────────────────────────
.sources-section {
border-radius: 14px;
padding: 18px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: none;
box-shadow: none;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
}
.section-link {
display: flex;
align-items: center;
gap: 4px;
color: var(--mat-sys-primary);
text-decoration: none;
font-size: 0.82rem;
font-weight: 500;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.75;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
// ─── Source Cards ─────────────────────────────────────────────────────────────
.sources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.source-card {
border-radius: 12px;
padding: 14px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
overflow: hidden;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: var(--mat-sys-primary);
background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.source-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
// Provider Badges
.provider-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
&--xtream {
background: rgba(129, 140, 248, 0.14);
color: var(--app-provider-xtream, #818cf8);
}
&--stalker {
background: rgba(52, 211, 153, 0.12);
color: var(--app-provider-stalker, #34d399);
}
&--m3u {
background: rgba(251, 191, 36, 0.12);
color: var(--app-provider-m3u, #fbbf24);
}
}
.source-card__count {
font-size: 0.72rem;
color: var(--mat-sys-on-surface-variant);
}
.source-card h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-card__date {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
flex-shrink: 0;
}
}
// ─── Empty State ──────────────────────────────────────────────────────────────
.empty-state {
border-radius: 14px;
border: 1px dashed var(--mat-sys-outline);
padding: 32px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background: var(--mat-sys-surface-container-low);
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
p {
margin: 0 0 6px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
max-width: 340px;
line-height: 1.5;
}
a {
text-decoration: none;
}
}
.empty-state__icon {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--mat-sys-surface-container-high);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: var(--mat-sys-on-surface-variant);
}
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (max-width: 1024px) {
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.hero-visual {
display: none;
}
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sources-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
:host {
padding: 12px;
}
.hero {
padding: 20px;
}
.stat-grid {
grid-template-columns: 1fr 1fr;
}
}
@@ -0,0 +1,98 @@
import { DecimalPipe } from '@angular/common';
import { Component, computed, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectActivePlaylist, selectAllPlaylistsMeta } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
@Component({
selector: 'app-dashboard-placeholder',
imports: [DecimalPipe, MatButton, MatIcon, RouterLink],
templateUrl: './dashboard-placeholder.component.html',
styleUrl: './dashboard-placeholder.component.scss',
})
export class DashboardPlaceholderComponent {
private readonly store = inject(Store);
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly stats = computed(() => {
const items = this.playlists();
return {
total: items.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
};
});
readonly recentPlaylists = computed(() =>
[...this.playlists()]
.sort(
(a, b) =>
this.getRecentTimestamp(b) - this.getRecentTimestamp(a)
)
.slice(0, 8)
);
getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
}
getPlaylistProvider(playlist: PlaylistMeta): 'Xtream' | 'Stalker' | 'M3U' {
if (playlist.serverUrl) {
return 'Xtream';
}
if (playlist.macAddress) {
return 'Stalker';
}
return 'M3U';
}
trackByPlaylistId(_: number, item: PlaylistMeta): string {
return item._id;
}
formatTimestamp(value?: string | number): string {
const timestamp = this.toTimestamp(value);
if (!timestamp) {
return 'Not yet synced';
}
return new Date(timestamp).toLocaleString();
}
private getRecentTimestamp(item: PlaylistMeta): number {
return (
this.toTimestamp(item.updateDate) ||
this.toTimestamp(item.importDate)
);
}
private toTimestamp(value?: string | number): number {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
}
@@ -0,0 +1,35 @@
import { Injectable, signal } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
/**
* Lightweight service that bridges the favorites/recently-viewed routed
* components (inside the router-outlet) with the WorkspaceShellComponent
* (the outer shell that renders the context panel aside).
*
* The routed component pushes its category list here via an effect;
* the workspace shell reads it to populate the context panel.
*/
@Injectable({ providedIn: 'root' })
export class FavoritesContextService {
/** Category list managed by the active routed component (Favorites / RecentlyViewed). */
readonly categories = signal<XtreamCategory[]>([]);
/** Currently selected category id — shared between panel and content. */
readonly selectedCategoryId = signal<string>('all');
/** Called by the context panel when the user clicks a category. */
setCategoryId(id: string): void {
this.selectedCategoryId.set(id);
}
/** Called by the routed component to push its category list into the panel. */
setCategories(categories: XtreamCategory[]): void {
this.categories.set(categories);
}
/** Reset to defaults when navigating away from favorites/recent sections. */
reset(): void {
this.categories.set([]);
this.selectedCategoryId.set('all');
}
}
@@ -0,0 +1,26 @@
import { Injectable, signal } from '@angular/core';
export interface SettingsNavItem {
id: string;
label: string;
icon: string;
}
@Injectable({ providedIn: 'root' })
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
readonly activeSection = signal<string>('general');
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
}
setActiveSection(id: string): void {
this.activeSection.set(id);
}
reset(): void {
this.sections.set([]);
this.activeSection.set('general');
}
}
@@ -0,0 +1,55 @@
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>{{ title() }}</h2>
@if (isXtreamCategories()) {
<button
mat-icon-button
[matTooltip]="'Manage categories'"
aria-label="Manage categories"
(click)="openXtreamCategoryManagement()"
>
<mat-icon>tune</mat-icon>
</button>
}
</div>
@if (subtitle(); as text) {
<span class="context-header__badge">{{ text }}</span>
}
</header>
<div class="context-divider"></div>
@if (isXtreamCategories()) {
<app-category-view
[items]="xtreamCategories()"
[selectedCategoryId]="xtreamSelectedCategoryId()"
[itemCounts]="xtreamCategoryItemCounts()"
[showCounts]="true"
(categoryClicked)="onXtreamCategoryClicked($event)"
/>
} @else if (isStalkerCategories()) {
@if (isStalkerCategoryLoading()) {
<ngx-skeleton-loader
count="18"
[theme]="{
width: '95%',
height: '36px',
borderRadius: '8px',
}"
/>
} @else if (isStalkerCategoryFailed()) {
<app-playlist-error-view
[title]="'Unable to load categories'"
[description]="'Please retry or verify portal settings.'"
[showActionButtons]="false"
/>
} @else {
<app-category-view
[items]="stalkerCategories()"
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
(categoryClicked)="onStalkerCategoryClicked($any($event))"
/>
}
}
</div>
@@ -0,0 +1,99 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
}
.context-column {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
}
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 4px 6px 10px;
flex-shrink: 0;
}
.context-header__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
button[mat-icon-button] {
width: 32px;
height: 32px;
line-height: 32px;
border-radius: 8px;
color: var(--mat-sys-on-surface-variant);
transition: background 0.15s ease, color 0.15s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
.context-header__badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 5px;
padding: 2px 8px;
border-radius: 999px;
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
}
// ─── Category List ────────────────────────────────────────────────────────────
app-category-view {
display: block;
min-height: 0;
height: 100%;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: var(--mat-sys-outline-variant) transparent;
}
@@ -0,0 +1,196 @@
import { Component, computed, inject, input } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistErrorViewComponent } from '../xtream-electron/playlist-error-view/playlist-error-view.component';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
import { StalkerStore } from '../stalker/stalker.store';
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
interface WorkspaceContextRoute {
provider: WorkspaceProvider;
playlistId: string;
}
interface XtreamCategoryLike {
id?: number | string;
category_id?: string;
}
@Component({
selector: 'app-workspace-context-panel',
imports: [
CategoryViewComponent,
MatIcon,
MatIconButton,
MatTooltip,
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
],
templateUrl: './workspace-context-panel.component.html',
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceContextPanelComponent {
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
readonly context = input.required<WorkspaceContextRoute>();
readonly section = input.required<string>();
readonly isXtreamCategories = computed(
() =>
this.context().provider === 'xtreams' &&
(this.section() === 'vod' ||
this.section() === 'series' ||
this.section() === 'live')
);
readonly isStalkerCategories = computed(
() =>
this.context().provider === 'stalker' &&
(this.section() === 'vod' ||
this.section() === 'series' ||
this.section() === 'itv')
);
readonly xtreamCategories = this.xtreamStore.getCategoriesBySelectedType;
readonly xtreamCategoryItemCounts = this.xtreamStore.getCategoryItemCounts;
readonly xtreamSelectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly stalkerCategories = this.stalkerStore.getCategoryResource;
readonly stalkerSelectedCategoryId = this.stalkerStore.selectedCategoryId;
readonly isStalkerCategoryLoading = this.stalkerStore.isCategoryResourceLoading;
readonly isStalkerCategoryFailed = this.stalkerStore.isCategoryResourceFailed;
readonly title = computed(() => {
if (this.isXtreamCategories()) {
if (this.section() === 'vod') return 'Movie Categories';
if (this.section() === 'series') return 'Series Categories';
return 'Live Categories';
}
if (this.isStalkerCategories()) {
if (this.section() === 'vod') return 'Movie Categories';
if (this.section() === 'series') return 'Series Categories';
return 'Live Categories';
}
return 'Categories';
});
readonly subtitle = computed(() => {
if (this.isXtreamCategories()) {
return 'Xtream source';
}
if (this.isStalkerCategories()) {
return 'Stalker portal';
}
return '';
});
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
const context = this.context();
const section = this.section();
const rawCategoryId = category.category_id ?? category.id;
if (rawCategoryId === undefined || rawCategoryId === null) {
return;
}
const numericCategoryId = Number(rawCategoryId);
if (Number.isNaN(numericCategoryId)) {
return;
}
const categoryId = numericCategoryId;
this.xtreamStore.setSelectedItem(null);
this.xtreamStore.setSelectedCategory(categoryId);
if (section === 'live') {
return;
}
this.router.navigate([
'/workspace',
'xtreams',
context.playlistId,
section,
categoryId,
]);
}
onStalkerCategoryClicked(item: { category_id?: string | number }): void {
const context = this.context();
const section = this.section();
const categoryId = String(item.category_id ?? '*');
this.stalkerStore.setSelectedCategory(categoryId);
this.stalkerStore.setPage(0);
this.stalkerStore.clearSelectedItem();
if (section === 'itv') {
return;
}
if (categoryId === '*') {
this.router.navigate([
'/workspace',
'stalker',
context.playlistId,
section,
]);
return;
}
this.router.navigate([
'/workspace',
'stalker',
context.playlistId,
section,
categoryId,
]);
}
openXtreamCategoryManagement(): void {
const context = this.context();
const section = this.section();
if (!this.isXtreamCategories()) {
return;
}
const contentType =
section === 'series'
? 'series'
: section === 'live'
? 'live'
: 'vod';
const dialogRef = this.dialog.open<
CategoryManagementDialogComponent,
CategoryManagementDialogData,
boolean
>(CategoryManagementDialogComponent, {
data: {
playlistId: context.playlistId,
contentType,
itemCounts: this.xtreamCategoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
});
dialogRef.afterClosed().subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
}
@@ -0,0 +1,63 @@
import { Component, computed, inject } from '@angular/core';
import { CategoryViewComponent } from '../xtream-electron/category-view/category-view.component';
import { FavoritesContextService } from './favorites-context.service';
/**
* Context panel rendered in the workspace-shell aside for
* favorites and recently-viewed sections.
*
* Reads categories from FavoritesContextService (populated by the
* routed FavoritesComponent / RecentlyViewedComponent) and emits
* category selections back through the same service.
*/
@Component({
selector: 'app-workspace-favorites-context-panel',
imports: [CategoryViewComponent],
template: `
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>{{ title() }}</h2>
</div>
@if (countBadge(); as text) {
<span class="context-header__badge">{{ text }}</span>
}
</header>
<div class="context-divider"></div>
<app-category-view
[items]="ctx.categories()"
[selectedCategoryId]="ctx.selectedCategoryId()"
(categoryClicked)="onCategoryClicked($any($event))"
/>
</div>
`,
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceFavoritesContextPanelComponent {
readonly ctx = inject(FavoritesContextService);
readonly title = computed(() => {
const categories = this.ctx.categories();
if (!categories.length) return 'Filter by type';
const selected = categories.find(
(c: any) => c.category_id === this.ctx.selectedCategoryId()
) as any;
return selected?.category_name ?? 'Filter by type';
});
readonly countBadge = computed(() => {
const categories = this.ctx.categories();
const selected = categories.find(
(c: any) => c.category_id === this.ctx.selectedCategoryId()
) as any;
if (!selected || selected.count === undefined) return '';
return `${selected.count} ${selected.count === 1 ? 'item' : 'items'}`;
});
onCategoryClicked(item: { category_id?: string | number }): void {
const id = String(item.category_id ?? 'all');
this.ctx.setCategoryId(id);
}
}
@@ -0,0 +1,77 @@
import { Location } from '@angular/common';
import { Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsContextService } from './settings-context.service';
@Component({
selector: 'app-workspace-settings-context-panel',
imports: [MatIconModule, TranslateModule],
styles: [
`
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.panel-title {
margin: 0;
padding: 16px 16px 0;
font-size: 20px;
font-weight: 500;
color: var(--mat-sys-on-surface);
}
.panel-header {
padding: 16px 16px 10px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
opacity: 0.6;
flex-shrink: 0;
}
`,
],
template: `
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
<div class="nav-list" style="margin-top: 16px">
<button
type="button"
class="nav-item"
(click)="onBack()"
style="margin-bottom: 8px;"
>
<mat-icon>arrow_back</mat-icon>
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
</button>
<p class="panel-header" style="padding-top: 8px;">
App preferences
</p>
<div class="nav-list">
@for (section of ctx.sections(); track section.id) {
<button
type="button"
class="nav-item"
[class.active]="ctx.activeSection() === section.id"
(click)="ctx.setActiveSection(section.id)"
>
<mat-icon>{{ section.icon }}</mat-icon>
<span>{{ section.label | translate }}</span>
</button>
}
</div>
</div>
`,
})
export class WorkspaceSettingsContextPanelComponent {
readonly ctx = inject(SettingsContextService);
private readonly location = inject(Location);
onBack() {
this.location.back();
}
}
@@ -0,0 +1,253 @@
<div class="workspace-shell">
<aside class="app-rail" [class.is-macos]="isMacOS">
<a
class="brand"
routerLink="/workspace/dashboard"
matTooltip="Dashboard"
[matTooltipPosition]="'right'"
aria-label="Open dashboard"
>
<img src="./assets/icons/icon-tv-256.png" alt="IPTVnator logo" />
</a>
<app-portal-rail-links
variant="rail"
activeClass="is-active"
[links]="workspaceLinks"
/>
@if (primaryContextLinks().length > 0) {
<div class="rail-divider"></div>
<app-portal-rail-links
variant="rail"
activeClass="is-active"
[selectedSection]="currentSection()"
[links]="primaryContextLinks()"
/>
}
@if (secondaryContextLinks().length > 0) {
<div class="rail-divider"></div>
<app-portal-rail-links
variant="rail"
activeClass="is-active"
[selectedSection]="currentSection()"
[links]="secondaryContextLinks()"
/>
}
<div class="rail-footer">
<a
routerLink="/workspace/settings"
[class.is-active]="isSettingsRoute()"
matTooltip="Settings"
[matTooltipPosition]="'right'"
aria-label="Open settings"
>
<mat-icon>settings</mat-icon>
</a>
</div>
</aside>
<header class="workspace-header">
<app-playlist-switcher
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
/>
<label class="search-field">
<mat-icon>search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchQuery()"
(input)="onSearchInput($event)"
(keydown.enter)="onSearchEnter($event)"
[disabled]="!canUseSearch()"
[placeholder]="searchPlaceholder()"
aria-label="Search"
/>
</label>
<div class="header-actions">
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="addPlaylistMenu().menu()"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
aria-label="Add playlist"
>
<mat-icon>add</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
@if (canOpenPlaylistInfo()) {
<button
type="button"
mat-icon-button
(click)="openPlaylistInfo()"
[matTooltip]="'PORTALS.SIDEBAR.PLAYLIST_INFO' | translate"
aria-label="Open playlist info"
>
<mat-icon>playlist_play</mat-icon>
</button>
}
@if (canOpenAccountInfo()) {
<button
type="button"
mat-icon-button
(click)="openAccountInfo()"
[matTooltip]="'PORTALS.SIDEBAR.ACCOUNT_INFO' | translate"
aria-label="Open account info"
>
<mat-icon>person</mat-icon>
</button>
}
@if (downloadsContext()) {
<button
type="button"
mat-icon-button
[class.is-active]="isDownloadsView()"
(click)="openDownloadsShortcut()"
[matTooltip]="'Downloads'"
aria-label="Open downloads"
>
<mat-icon>download</mat-icon>
</button>
}
@if (headerBulkAction(); as action) {
<button
type="button"
mat-icon-button
(click)="runHeaderBulkAction()"
[matTooltip]="action.tooltip"
[disabled]="action.disabled"
[attr.aria-label]="action.ariaLabel"
>
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
<button
type="button"
mat-icon-button
[class.is-active]="isFavoritesView()"
(click)="toggleFavoritesShortcut()"
[matTooltip]="'Favorites only'"
[disabled]="!currentContext()"
aria-label="Toggle favorites only"
>
<mat-icon>favorite</mat-icon>
</button>
@if (canSort()) {
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="sortMenu"
[matTooltip]="'Sort: ' + contentSortLabel()"
aria-label="Sorting options"
>
<mat-icon>sort</mat-icon>
</button>
<mat-menu #sortMenu="matMenu">
<button
mat-menu-item
(click)="setContentSortMode('date-desc')"
>
<span>Date Added (Latest First)</span>
@if (contentSortMode() === 'date-desc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('date-asc')"
>
<span>Date Added (Oldest First)</span>
@if (contentSortMode() === 'date-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-asc')"
>
<span>Name A-Z</span>
@if (contentSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setContentSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (contentSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
}
</div>
</header>
<div class="workspace-body" [class.single-pane]="!showContextPanel()">
@if (isSourcesRoute()) {
<aside
class="context-panel context-panel--sources"
appResizable
[minWidth]="220"
[maxWidth]="440"
[defaultWidth]="290"
storageKey="workspace-sources-panel-width"
>
<app-workspace-sources-filters-panel />
</aside>
} @else if (isCategoryContextRoute()) {
@if (currentContext(); as context) {
@if (currentSection(); as section) {
<aside
class="context-panel context-panel--route"
appResizable
[minWidth]="240"
[maxWidth]="560"
[defaultWidth]="300"
storageKey="workspace-context-panel-width"
>
<app-workspace-context-panel
[context]="context"
[section]="section"
/>
</aside>
}
}
} @else if (isSettingsRoute()) {
<aside
class="context-panel context-panel--settings"
appResizable
[minWidth]="200"
[maxWidth]="400"
[defaultWidth]="260"
storageKey="workspace-settings-panel-width"
>
<app-workspace-settings-context-panel />
</aside>
} @else if (isFavoritesContextRoute()) {
<aside
class="context-panel context-panel--route"
appResizable
[minWidth]="200"
[maxWidth]="400"
[defaultWidth]="260"
storageKey="workspace-favorites-panel-width"
>
<app-workspace-favorites-context-panel />
</aside>
}
<main class="workspace-content">
<router-outlet />
</main>
</div>
</div>
@@ -0,0 +1,442 @@
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
}
// ─── Shell Layout ─────────────────────────────────────────────────────────────
.workspace-shell {
display: grid;
grid-template-columns: 60px 1fr;
grid-template-rows: 56px 1fr;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--app-shell-bg, var(--mat-sys-surface));
color: var(--mat-sys-on-surface);
}
// ─── App Rail (Sidebar) ───────────────────────────────────────────────────────
.app-rail {
grid-column: 1;
grid-row: 1 / span 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 10px 8px;
border-right: 1px solid var(--app-rail-border, var(--mat-sys-outline-variant));
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
app-region: drag;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
&.is-macos {
padding-top: 36px;
}
&::-webkit-scrollbar {
display: none;
}
}
.app-rail {
--rail-link-color: var(--mat-sys-on-surface-variant);
--rail-link-hover-color: var(--mat-sys-on-surface);
--rail-link-hover-bg: var(--mat-sys-surface-container);
--rail-link-active-bg: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
--rail-link-active-color: var(--mat-sys-primary);
--rail-link-active-icon: var(--mat-sys-primary);
--rail-link-active-border: color-mix(in srgb, var(--mat-sys-primary) 42%, transparent);
--rail-link-active-glow: color-mix(in srgb, var(--mat-sys-primary) 24%, transparent);
}
.app-rail a {
width: 44px;
height: 44px;
border-radius: 14px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rail-link-color);
text-decoration: none;
transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
app-region: no-drag;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.app-rail a:hover {
color: var(--rail-link-hover-color);
background: var(--rail-link-hover-bg);
}
.app-rail a.is-active,
.app-rail a.active {
color: var(--rail-link-active-color);
background: var(--rail-link-active-bg);
box-shadow:
inset 0 0 0 1px var(--rail-link-active-border),
0 10px 20px -16px var(--rail-link-active-glow);
mat-icon {
color: var(--rail-link-active-icon);
}
&::after {
content: '';
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 18px;
border-radius: 999px;
background: var(--rail-link-active-icon);
box-shadow: 0 0 10px var(--rail-link-active-glow);
}
}
// ─── Brand Logo ───────────────────────────────────────────────────────────────
.brand {
margin-bottom: 10px;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
width: 40px;
height: 40px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand img {
width: 24px;
height: 24px;
object-fit: contain;
}
// ─── Rail Divider ─────────────────────────────────────────────────────────────
.rail-divider {
width: 24px;
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 6px 0;
opacity: 0.5;
}
.rail-footer {
margin-top: auto;
}
// ─── Workspace Header ─────────────────────────────────────────────────────────
.workspace-header {
grid-column: 2;
grid-row: 1;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
height: 56px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
background: var(--app-header-bg, var(--mat-sys-surface-container-low));
app-region: drag;
}
.playlist-switcher {
width: min(260px, 32vw);
flex-shrink: 0;
app-region: no-drag;
}
.settings-header-title {
margin: 0;
font-size: 1.15rem;
font-weight: 700;
color: var(--mat-sys-on-surface);
app-region: no-drag;
}
// ─── Search Field ─────────────────────────────────────────────────────────────
.search-field {
flex: 1;
min-width: 160px;
height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
app-region: no-drag;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px rgba(var(--mat-sys-primary), 0.12);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.875rem;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
}
}
}
// ─── Header Actions ───────────────────────────────────────────────────────────
.header-actions {
display: flex;
align-items: center;
gap: 2px;
app-region: no-drag;
button {
color: var(--mat-sys-on-surface-variant);
border-radius: 8px;
transition:
color 0.15s ease,
background 0.15s ease;
&:hover {
color: var(--mat-sys-on-surface);
background: var(--mat-sys-surface-container);
}
&.is-active {
color: var(--mat-sys-on-primary-container);
background: var(--mat-sys-primary-container);
}
}
}
// ─── Workspace Body ───────────────────────────────────────────────────────────
.workspace-body {
grid-column: 2;
grid-row: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
&.single-pane {
grid-template-columns: 1fr;
}
}
// ─── Context Panel ────────────────────────────────────────────────────────────
.context-panel {
border-right: 1px solid var(--mat-sys-outline-variant);
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
width: 300px;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.context-panel--route {
padding: 12px 10px 10px 12px;
overflow: visible;
}
.context-panel--sources {
padding: 12px 10px 10px 12px;
overflow: visible;
}
.context-panel--route app-workspace-context-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.context-panel--sources app-workspace-sources-filters-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.context-panel__header {
padding: 6px 6px 12px;
}
.context-panel__title {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.context-panel__subtitle {
margin-top: 2px;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
.context-item {
width: 100%;
min-height: 36px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
text-align: left;
padding: 0 10px;
cursor: pointer;
font: inherit;
font-size: 0.875rem;
transition:
all 0.15s ease,
border-color 0.15s ease;
&:hover {
border-color: var(--mat-sys-outline-variant);
color: var(--mat-sys-on-surface);
background: var(--mat-sys-surface-container);
}
&.is-active {
border-color: transparent;
color: var(--mat-sys-on-primary-container);
background: var(--mat-sys-primary-container);
font-weight: 500;
}
}
.context-panel__footer {
margin-top: auto;
border-top: 1px solid var(--mat-sys-outline-variant);
padding: 12px 6px 2px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.82rem;
p {
margin: 0 0 8px;
line-height: 1.4;
}
a {
color: var(--mat-sys-primary);
text-decoration: none;
font-weight: 600;
}
}
// ─── Main Content Area ────────────────────────────────────────────────────────
.workspace-content {
min-width: 0;
min-height: 0;
overflow: auto;
background: var(--app-content-bg, var(--mat-sys-surface));
// Distinctive top-left inset radius (GitButler-style panel inset)
border-radius: 16px 0 0 0;
box-shadow: -4px 8px 20px rgba(0, 0, 0, 0.18);
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (max-width: 780px) {
.workspace-shell {
grid-template-columns: 56px 1fr;
grid-template-rows: 56px 1fr;
}
.workspace-header {
padding-inline: 10px;
gap: 10px;
}
.playlist-switcher {
width: 190px;
}
.header-actions {
gap: 0;
}
}
@media (max-width: 640px) {
.workspace-shell {
grid-template-columns: 1fr;
grid-template-rows: 52px 56px 1fr;
}
.app-rail {
grid-column: 1;
grid-row: 1;
flex-direction: row;
justify-content: space-between;
padding: 6px 10px;
border-right: none;
border-bottom: 1px solid var(--mat-sys-outline-variant);
.brand {
margin-bottom: 0;
}
}
.rail-divider {
width: 1px;
height: 24px;
margin: 0 4px;
}
.rail-footer {
margin-top: 0;
}
.workspace-header {
grid-column: 1;
grid-row: 2;
}
.playlist-switcher {
width: auto;
min-width: 140px;
flex: 1;
}
.workspace-body {
grid-column: 1;
grid-row: 3;
}
}
@@ -0,0 +1,897 @@
import {
Component,
computed,
DestroyRef,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
import {
NavigationEnd,
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
AddPlaylistMenuComponent,
PlaylistInfoComponent,
PlaylistSwitcherComponent,
PlaylistType,
ResizableDirective,
} from 'components';
import {
selectActivePlaylist,
selectAllPlaylistsMeta,
selectPlaylistTitle,
} from 'm3u-state';
import { filter, firstValueFrom } from 'rxjs';
import { PlaylistsService } from 'services';
import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
import { DownloadsService } from '../services/downloads.service';
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
import {
buildPortalRailLinks,
PortalRailLink,
} from '../shared/navigation/portal-rail-links';
import { PortalRailLinksComponent } from '../shared/navigation/portal-rail-links.component';
import { AccountInfoComponent } from '../xtream-electron/account-info/account-info.component';
import { XtreamCategorySortMode } from '../xtream-electron/stores/features/with-selection.feature';
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
import { FavoritesContextService } from './favorites-context.service';
import { SettingsContextService } from './settings-context.service';
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
import { WorkspaceFavoritesContextPanelComponent } from './workspace-favorites-context-panel.component';
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
import { WorkspaceSourcesFiltersPanelComponent } from './workspace-sources-filters-panel.component';
interface WorkspaceContext {
provider: 'xtreams' | 'stalker' | 'playlists';
playlistId: string;
}
interface WorkspaceHeaderBulkAction {
icon: string;
tooltip: string;
ariaLabel: string;
disabled: boolean;
}
const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
const SEARCH_INPUT_DEBOUNCE_MS = 350;
@Component({
selector: 'app-workspace-shell',
imports: [
MatIcon,
MatIconButton,
MatMenuModule,
MatTooltip,
AddPlaylistMenuComponent,
PlaylistSwitcherComponent,
TranslatePipe,
RouterLink,
RouterLinkActive,
RouterOutlet,
WorkspaceContextPanelComponent,
WorkspaceFavoritesContextPanelComponent,
WorkspaceSettingsContextPanelComponent,
WorkspaceSourcesFiltersPanelComponent,
PortalRailLinksComponent,
ResizableDirective,
],
templateUrl: './workspace-shell.component.html',
styleUrl: './workspace-shell.component.scss',
})
export class WorkspaceShellComponent {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly xtreamStore = inject(XtreamStore);
private readonly destroyRef = inject(DestroyRef);
private readonly downloadsService = inject(DownloadsService);
private readonly playlistsService = inject(PlaylistsService);
private readonly dialog = inject(MatDialog);
readonly favoritesCtx = inject(FavoritesContextService);
readonly settingsCtx = inject(SettingsContextService);
private searchDebounceTimeoutId: ReturnType<typeof setTimeout> | null =
null;
readonly addPlaylistMenu = viewChild.required(AddPlaylistMenuComponent);
readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle);
private readonly activePlaylist =
this.store.selectSignal(selectActivePlaylist);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly searchQuery = signal('');
readonly appliedSearchQuery = signal('');
readonly lastM3uPlaylistId = signal<string | null>(null);
readonly isElectron = !!window.electron;
readonly isMacOS = window.electron?.platform === 'darwin';
readonly workspaceLinks: PortalRailLink[] = [
{
icon: 'dashboard',
tooltip: 'Dashboard',
path: ['/workspace/dashboard'],
exact: true,
},
{
icon: 'library_books',
tooltip: 'Sources',
path: ['/workspace/sources'],
},
];
readonly currentUrl = signal(this.router.url);
readonly currentContext = computed<WorkspaceContext | null>(() =>
this.parseWorkspaceContext(this.currentUrl())
);
readonly currentSection = computed<string | null>(() =>
this.parseWorkspaceSection(this.currentUrl())
);
readonly railContext = computed<WorkspaceContext | null>(() => {
const routeContext = this.currentContext();
if (routeContext) {
return routeContext;
}
// On dashboard, sources, and settings we still want provider-specific rail links
// for the currently active playlist selected in the switcher.
if (
!this.isDashboardRoute() &&
!this.isSourcesRoute() &&
!this.isSettingsRoute()
) {
return null;
}
const activePlaylist = this.activePlaylist();
if (!activePlaylist?._id) {
return null;
}
return {
provider: this.getProviderFromPlaylist(activePlaylist),
playlistId: activePlaylist._id,
};
});
readonly isDashboardRoute = computed(() =>
/^\/workspace(?:\/dashboard)?(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
);
readonly isSourcesRoute = computed(() =>
/^\/workspace\/sources(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
);
readonly isSettingsRoute = computed(() =>
/^\/workspace\/settings(?:\/)?(?:\?.*)?$/.test(this.currentUrl())
);
readonly dashboardXtreamContext = computed<WorkspaceContext | null>(() => {
if (!this.isDashboardRoute()) {
return null;
}
const context = this.railContext();
if (!context || context.provider !== 'xtreams') {
return null;
}
return context;
});
readonly isCategoryContextRoute = computed(() => {
const context = this.currentContext();
const section = this.currentSection();
return (
!!context &&
((context.provider === 'xtreams' &&
(section === 'vod' ||
section === 'series' ||
section === 'live')) ||
(context.provider === 'stalker' &&
(section === 'vod' ||
section === 'series' ||
section === 'itv')))
);
});
readonly isFavoritesContextRoute = computed(() => {
const context = this.currentContext();
const section = this.currentSection();
if (!context) return false;
return (
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
(section === 'favorites' || section === 'recent')
);
});
readonly showContextPanel = computed(
() =>
this.isSourcesRoute() ||
this.isSettingsRoute() ||
this.isCategoryContextRoute() ||
this.isFavoritesContextRoute()
);
readonly isFavoritesView = computed(() => {
const context = this.currentContext();
if (
context?.provider === 'playlists' &&
context.playlistId === GLOBAL_FAVORITES_PLAYLIST_ID
) {
return true;
}
return this.currentSection() === 'favorites';
});
readonly canUseSearch = computed(() => {
if (this.isSettingsRoute()) {
return false;
}
if (this.isSourcesRoute()) {
return true;
}
if (this.dashboardXtreamContext()) {
return true;
}
const context = this.currentContext();
const section = this.currentSection();
if (context?.provider === 'xtreams') {
return (
section === 'vod' ||
section === 'series' ||
section === 'search' ||
this.usesRouteQuerySearch(context, section)
);
}
return context?.provider === 'stalker';
});
readonly searchPlaceholder = computed(() => {
if (this.isSourcesRoute()) {
return 'Search sources...';
}
const context = this.currentContext();
const section = this.currentSection();
if (!context) {
if (this.dashboardXtreamContext()) {
return 'Search in active playlist...';
}
return 'Search channels, movies, series...';
}
if (context.provider === 'xtreams') {
if (section === 'search') {
return 'Search in playlist content...';
}
if (section === 'vod' || section === 'series') {
return 'Search in current category...';
}
if (section === 'favorites') {
return 'Filter favorites...';
}
if (section === 'recent') {
return 'Filter recently viewed...';
}
if (section === 'recently-added') {
return 'Filter recently added...';
}
if (section === 'downloads') {
return 'Filter downloads...';
}
}
if (context.provider === 'stalker') {
if (section === 'search') {
return 'Search movies and series in this portal...';
}
if (section === 'favorites') {
return 'Filter favorites...';
}
if (section === 'recent') {
return 'Filter recently viewed...';
}
return 'Search this portal...';
}
return 'Search is not available for this section';
});
readonly canSort = computed(() => {
const context = this.currentContext();
const section = this.currentSection();
return context?.provider === 'xtreams'
? section === 'vod' || section === 'series'
: false;
});
readonly contentSortMode = this.xtreamStore.contentSortMode;
readonly contentSortLabel = computed(() => {
const mode = this.contentSortMode();
if (mode === 'date-asc') return 'Date Added (Oldest First)';
if (mode === 'name-asc') return 'Name A-Z';
if (mode === 'name-desc') return 'Name Z-A';
return 'Date Added (Latest First)';
});
readonly primaryContextLinks = computed<PortalRailLink[]>(() => {
const context = this.railContext();
if (!context) return [];
return buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
isElectron: this.isElectron,
workspace: true,
}).primary;
});
readonly secondaryContextLinks = computed<PortalRailLink[]>(() => {
const context = this.railContext();
if (!context) return [];
return buildPortalRailLinks({
provider: context.provider,
playlistId: context.playlistId,
isElectron: this.isElectron,
workspace: true,
}).secondary.filter((link) => link.section !== 'downloads');
});
readonly downloadsContext = computed<WorkspaceContext | null>(() => {
if (!this.isElectron) {
return null;
}
const context = this.currentContext() ?? this.railContext();
if (
!context ||
(context.provider !== 'xtreams' && context.provider !== 'stalker')
) {
return null;
}
return context;
});
readonly isDownloadsView = computed(
() => this.currentSection() === 'downloads'
);
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.activePlaylist())
);
readonly canOpenAccountInfo = computed(() =>
Boolean(this.activePlaylist()?.serverUrl)
);
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
() => {
const context = this.currentContext();
const section = this.currentSection();
if (!context || !section) {
return null;
}
if (
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
section === 'downloads'
) {
const hasClearable = this.downloadsService
.downloads()
.some(
(item) =>
item.playlistId === context.playlistId &&
(item.status === 'completed' ||
item.status === 'failed' ||
item.status === 'canceled')
);
return {
icon: 'delete_sweep',
tooltip: 'Clear completed downloads',
ariaLabel: 'Clear completed downloads',
disabled: !hasClearable,
};
}
if (context.provider === 'xtreams' && section === 'recent') {
return {
icon: 'delete_sweep',
tooltip: 'Clear recently viewed',
ariaLabel: 'Clear recently viewed',
disabled: this.xtreamStore.recentItems().length === 0,
};
}
if (context.provider === 'stalker' && section === 'recent') {
return {
icon: 'delete_sweep',
tooltip: 'Clear recently viewed',
ariaLabel: 'Clear recently viewed',
disabled: false,
};
}
return null;
}
);
constructor() {
this.destroyRef.onDestroy(() => {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
this.searchDebounceTimeoutId = null;
}
});
const savedSortMode = localStorage.getItem(
XTREAM_CATEGORY_SORT_STORAGE_KEY
);
if (
savedSortMode === 'date-desc' ||
savedSortMode === 'date-asc' ||
savedSortMode === 'name-asc' ||
savedSortMode === 'name-desc'
) {
this.xtreamStore.setContentSortMode(savedSortMode);
}
this.router.events
.pipe(
filter(
(event): event is NavigationEnd =>
event instanceof NavigationEnd
),
takeUntilDestroyed()
)
.subscribe((event) => {
this.currentUrl.set(event.urlAfterRedirects);
this.syncSearchFromRoute();
});
this.syncSearchFromRoute();
effect(() => {
const context = this.currentContext();
const section = this.currentSection();
const term = this.appliedSearchQuery();
if (!context || context.provider !== 'xtreams') {
return;
}
if (section === 'search') {
this.xtreamStore.setSearchTerm(term);
return;
}
if (section === 'vod' || section === 'series') {
this.xtreamStore.setCategorySearchTerm(term);
}
});
effect(() => {
const context = this.currentContext();
const section = this.currentSection();
const term = this.appliedSearchQuery();
if (context?.provider === 'stalker' && section === 'search') {
this.syncStalkerSearchQueryParam(term);
}
});
effect(() => {
const context = this.currentContext();
const section = this.currentSection();
const useQuerySearch =
this.isSourcesRoute() ||
this.usesRouteQuerySearch(context, section);
if (!useQuerySearch) {
return;
}
this.syncSearchQueryParam(this.appliedSearchQuery());
});
effect(() => {
const context = this.currentContext();
if (!context || context.provider !== 'playlists') {
return;
}
if (context.playlistId !== GLOBAL_FAVORITES_PLAYLIST_ID) {
this.lastM3uPlaylistId.set(context.playlistId);
}
});
}
readonly playlistSubtitle = computed(() => {
const active = this.activePlaylist();
if (active?.serverUrl) return 'Xtream Code';
if (active?.macAddress) return 'Stalker Portal';
if (active?.count) return `${active.count} channels`;
const sourcesCount = this.playlists().length;
if (sourcesCount === 0) return 'No sources available';
if (sourcesCount === 1) return '1 source available';
return `${sourcesCount} sources available`;
});
onSearchInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
const value = target?.value ?? '';
this.searchQuery.set(value);
this.scheduleSearchApply(value);
}
onSearchEnter(event: Event): void {
const target = event.target as HTMLInputElement | null;
const value = (target?.value ?? this.searchQuery()).trim();
this.searchQuery.set(value);
if (!value) {
this.applySearchQuery('');
return;
}
// Dashboard + active Xtream source should jump into global playlist search.
const dashboardXtream = this.dashboardXtreamContext();
if (dashboardXtream) {
event.preventDefault();
this.xtreamStore.setSearchTerm(value);
this.applySearchQuery(value);
this.router.navigate(
['/workspace', 'xtreams', dashboardXtream.playlistId, 'search'],
{
queryParams: { q: value },
}
);
return;
}
// For other contexts, Enter applies immediately instead of waiting debounce.
this.applySearchQuery(value);
}
openAddPlaylistDialog(type: PlaylistType): void {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type },
}
);
}
async runHeaderBulkAction(): Promise<void> {
const context = this.currentContext();
const section = this.currentSection();
if (!context || !section) {
return;
}
if (
(context.provider === 'xtreams' ||
context.provider === 'stalker') &&
section === 'downloads'
) {
await this.downloadsService.clearCompleted(context.playlistId);
await this.downloadsService.loadDownloads(context.playlistId);
return;
}
if (context.provider === 'xtreams' && section === 'recent') {
this.xtreamStore.clearRecentItems({ id: context.playlistId });
return;
}
if (context.provider === 'stalker' && section === 'recent') {
await firstValueFrom(
this.playlistsService.clearPortalRecentlyViewed(
context.playlistId
)
);
this.bumpRefreshQueryParam();
}
}
toggleFavoritesShortcut(): void {
const context = this.currentContext();
if (!context) return;
if (context.provider === 'xtreams') {
if (!this.isElectron) return;
const base = ['/workspace', 'xtreams', context.playlistId];
this.router.navigate(
this.isFavoritesView()
? [...base, 'vod']
: [...base, 'favorites']
);
return;
}
if (context.provider === 'stalker') {
const base = ['/workspace', 'stalker', context.playlistId];
this.router.navigate(
this.isFavoritesView()
? [...base, 'vod']
: [...base, 'favorites']
);
return;
}
if (context.provider === 'playlists') {
if (context.playlistId === GLOBAL_FAVORITES_PLAYLIST_ID) {
const fallbackPlaylist = this.lastM3uPlaylistId();
if (fallbackPlaylist) {
this.router.navigate([
'/workspace',
'playlists',
fallbackPlaylist,
]);
} else {
this.router.navigate(['/workspace', 'dashboard']);
}
return;
}
this.router.navigate([
'/workspace',
'playlists',
GLOBAL_FAVORITES_PLAYLIST_ID,
]);
}
}
openDownloadsShortcut(): void {
const context = this.downloadsContext();
if (!context) {
return;
}
this.router.navigate([
'/workspace',
context.provider,
context.playlistId,
'downloads',
]);
}
openPlaylistInfo(): void {
const playlist = this.activePlaylist();
if (!playlist) {
return;
}
this.dialog.open(PlaylistInfoComponent, {
data: playlist,
});
}
openAccountInfo(): void {
if (!this.canOpenAccountInfo()) {
return;
}
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data: {
vodStreamsCount: this.xtreamStore.vodStreams().length,
liveStreamsCount: this.xtreamStore.liveStreams().length,
seriesCount: this.xtreamStore.serialStreams().length,
},
});
}
setContentSortMode(mode: XtreamCategorySortMode): void {
this.xtreamStore.setContentSortMode(mode);
localStorage.setItem(XTREAM_CATEGORY_SORT_STORAGE_KEY, mode);
}
private parseWorkspaceContext(url: string): WorkspaceContext | null {
const match = url.match(
/^\/workspace\/(xtreams|stalker|playlists)\/([^\/\?]+)/
);
if (!match) return null;
return {
provider: match[1] as WorkspaceContext['provider'],
playlistId: match[2],
};
}
private parseWorkspaceSection(url: string): string | null {
const match = url.match(
/^\/workspace\/(?:xtreams|stalker|playlists)\/[^\/\?]+\/([^\/\?]+)/
);
if (!match) return null;
return match[1];
}
private syncSearchFromRoute(): void {
const context = this.currentContext();
const section = this.currentSection();
if (context?.provider === 'xtreams') {
if (section === 'search') {
const queryTerm = this.getRouteQueryParam('q');
this.setSearchState(queryTerm || this.xtreamStore.searchTerm());
return;
}
if (section === 'vod' || section === 'series') {
this.setSearchState(this.xtreamStore.categorySearchTerm());
return;
}
}
if (context?.provider === 'stalker' && section === 'search') {
this.setSearchState(this.getRouteQueryParam('q'));
return;
}
if (
this.isSourcesRoute() ||
this.usesRouteQuerySearch(context, section)
) {
this.setSearchState(this.getRouteQueryParam('q'));
return;
}
this.setSearchState('');
}
private setSearchState(value: string): void {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
this.searchDebounceTimeoutId = null;
}
this.searchQuery.set(value);
this.appliedSearchQuery.set(value);
}
private scheduleSearchApply(value: string): void {
if (this.searchDebounceTimeoutId !== null) {
clearTimeout(this.searchDebounceTimeoutId);
}
this.searchDebounceTimeoutId = setTimeout(() => {
this.searchDebounceTimeoutId = null;
this.applySearchQuery(value);
}, SEARCH_INPUT_DEBOUNCE_MS);
}
private applySearchQuery(value: string): void {
this.appliedSearchQuery.set(value);
const context = this.currentContext();
if (
context?.provider === 'stalker' &&
this.currentSection() !== 'search' &&
!this.usesRouteQuerySearch(context, this.currentSection()) &&
value.trim().length > 0
) {
this.router.navigate(
['/workspace', 'stalker', context.playlistId, 'search'],
{
queryParams: {
q: value.trim(),
},
}
);
}
}
private syncStalkerSearchQueryParam(term: string): void {
const nextTerm = term.trim();
const currentTerm = this.getRouteQueryParam('q');
if (nextTerm === currentTerm) {
return;
}
this.syncSearchQueryParam(nextTerm);
}
private syncSearchQueryParam(term: string): void {
const nextTerm = term.trim();
const currentTerm = this.getRouteQueryParam('q');
if (nextTerm === currentTerm) {
return;
}
const routePath = this.currentUrl().split('?')[0];
const queryParams = {
...this.router.parseUrl(this.currentUrl()).queryParams,
};
if (nextTerm.length > 0) {
queryParams['q'] = nextTerm;
} else {
delete queryParams['q'];
}
const queryString = this.toQueryString(queryParams);
const nextUrl = queryString ? `${routePath}?${queryString}` : routePath;
this.router.navigateByUrl(nextUrl, { replaceUrl: true });
}
private getRouteQueryParam(name: string): string {
const value = this.router.parseUrl(this.currentUrl()).queryParams[name];
return typeof value === 'string' ? value : '';
}
private toQueryString(queryParams: Record<string, unknown>): string {
const urlSearchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value == null) {
return;
}
if (Array.isArray(value)) {
value.forEach((item) =>
urlSearchParams.append(key, String(item))
);
return;
}
urlSearchParams.set(key, String(value));
});
return urlSearchParams.toString();
}
private bumpRefreshQueryParam(): void {
const routePath = this.currentUrl().split('?')[0];
const queryParams = {
...this.router.parseUrl(this.currentUrl()).queryParams,
refresh: Date.now().toString(),
};
const queryString = this.toQueryString(queryParams);
const nextUrl = queryString ? `${routePath}?${queryString}` : routePath;
this.router.navigateByUrl(nextUrl, { replaceUrl: true });
}
private usesRouteQuerySearch(
context: WorkspaceContext | null,
section: string | null
): boolean {
if (!context || !section) {
return false;
}
if (context.provider === 'xtreams') {
return (
section === 'favorites' ||
section === 'downloads' ||
section === 'recent' ||
section === 'recently-added'
);
}
if (context.provider === 'stalker') {
return section === 'favorites' || section === 'recent';
}
return false;
}
private getProviderFromPlaylist(playlist: {
serverUrl?: string;
macAddress?: string;
}): WorkspaceContext['provider'] {
if (playlist.serverUrl) {
return 'xtreams';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'playlists';
}
}
@@ -0,0 +1,57 @@
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>Sources</h2>
</div>
<p>Filter and sort playlists</p>
</header>
<div class="context-divider"></div>
<section class="panel-section">
<div class="nav-list option-list">
<h3 class="panel-section-title">Type</h3>
@for (option of typeOptions; track option.id) {
<a
href="#type-{{ option.id }}"
class="nav-item category-item option-row"
[class.selected]="isTypeActive(option.id)"
(click)="$event.preventDefault(); selectType(option.id)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span class="nav-item-label option-label">
{{
option.translationKey
? (option.translationKey | translate)
: option.label
}}
</span>
<div class="nav-item-meta option-meta">
{{ getTypeCount(option.id) }}
</div>
</a>
}
</div>
</section>
<section class="panel-section">
<div class="nav-list option-list">
<h3 class="panel-section-title">
{{ 'HOME.SORT_BY' | translate }}
</h3>
@for (option of sortOptions; track option.translationKey) {
<a
href="#sort-{{ option.translationKey }}"
class="nav-item category-item option-row"
[class.selected]="isSortActive(option)"
(click)="$event.preventDefault(); setSortOption(option)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span class="nav-item-label option-label">{{
option.translationKey | translate
}}</span>
</a>
}
</div>
</section>
</div>
@@ -0,0 +1,72 @@
:host {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
}
.context-column {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
}
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 4px 6px 10px;
flex-shrink: 0;
}
.context-header__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
}
.panel-section {
display: flex;
flex-direction: column;
min-height: 0;
}
.option-list {
overflow-y: auto;
min-height: 0;
}
.panel-section-title {
margin: 0;
padding: 10px 10px 6px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mat-sys-on-surface-variant);
}
@@ -0,0 +1,162 @@
import { computed, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { Store } from '@ngrx/store';
import {
FilterActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from 'm3u-state';
import { TranslatePipe } from '@ngx-translate/core';
import { SortBy, SortOrder, SortService } from 'services';
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
interface PlaylistFilterOption {
id: PlaylistFilterId;
icon: string;
label: string;
translationKey?: string;
}
interface SortOption {
by: SortBy;
order: SortOrder;
icon: string;
translationKey: string;
}
const ALL_FILTERS = ['m3u', 'xtream', 'stalker'];
@Component({
selector: 'app-workspace-sources-filters-panel',
imports: [MatIcon, MatListModule, TranslatePipe],
templateUrl: './workspace-sources-filters-panel.component.html',
styleUrl: './workspace-sources-filters-panel.component.scss',
})
export class WorkspaceSourcesFiltersPanelComponent {
private readonly store = inject(Store);
private readonly sortService = inject(SortService);
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly currentSortOptions = toSignal(this.sortService.getSortOptions(), {
requireSync: true,
});
readonly typeOptions: PlaylistFilterOption[] = [
{
id: 'all',
icon: 'layers',
label: 'All',
},
{
id: 'm3u',
icon: 'playlist_play',
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
label: 'M3U',
},
{
id: 'xtream',
icon: 'cloud',
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
label: 'Xtream',
},
{
id: 'stalker',
icon: 'router',
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
label: 'Stalker',
},
];
readonly sortOptions: SortOption[] = [
{
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
icon: 'schedule',
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
},
{
by: SortBy.DATE_ADDED,
order: SortOrder.ASC,
icon: 'history',
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
},
{
by: SortBy.NAME,
order: SortOrder.ASC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
},
{
by: SortBy.NAME,
order: SortOrder.DESC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
},
{
by: SortBy.CUSTOM,
order: SortOrder.ASC,
icon: 'drag_indicator',
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
},
];
readonly typeCounts = computed(() => {
const items = this.playlists();
return {
all: items.length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
};
});
isTypeActive(filterId: PlaylistFilterId): boolean {
const selected = this.activeTypeFilters();
if (filterId === 'all') {
return (
selected.length === ALL_FILTERS.length &&
ALL_FILTERS.every((id) => selected.includes(id))
);
}
return selected.length === 1 && selected[0] === filterId;
}
selectType(filterId: PlaylistFilterId): void {
const selectedFilters =
filterId === 'all' ? ALL_FILTERS : [filterId];
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters,
})
);
}
getTypeCount(filterId: PlaylistFilterId): number {
const counts = this.typeCounts();
if (filterId === 'all') {
return counts.all;
}
return counts[filterId];
}
isSortActive(option: SortOption): boolean {
const current = this.currentSortOptions();
return current.by === option.by && current.order === option.order;
}
setSortOption(option: SortOption): void {
this.sortService.setSortOptions({
by: option.by,
order: option.order,
});
}
}
@@ -0,0 +1,12 @@
<section class="sources-page">
<header class="sources-header">
<h2 class="sources-title">{{ title() }}</h2>
<p class="sources-subtitle">{{ subtitle() }}</p>
</header>
<div class="sources-content">
<app-recent-playlists
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
</div>
</section>
@@ -0,0 +1,45 @@
:host {
display: block;
height: 100%;
min-height: 100%;
}
.sources-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
min-width: 0;
overflow: hidden;
}
.sources-header {
position: sticky;
top: 0;
z-index: 2;
padding: 20px 20px 8px;
}
.sources-title {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
}
.sources-subtitle {
margin: 4px 0 0;
font-size: 0.875rem;
color: var(--mat-sys-on-surface-variant, rgba(255, 255, 255, 0.6));
}
.sources-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 20px 20px;
}
.sources-content app-recent-playlists {
display: block;
height: 100%;
}
@@ -0,0 +1,92 @@
import { map } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from 'm3u-state';
import { PlaylistType, RecentPlaylistsComponent } from 'components';
import { AddPlaylistDialogComponent } from '../shared/components/add-playlist/add-playlist-dialog.component';
@Component({
selector: 'app-workspace-sources',
imports: [RecentPlaylistsComponent],
templateUrl: './workspace-sources.component.html',
styleUrl: './workspace-sources.component.scss',
})
export class WorkspaceSourcesComponent {
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly searchQuery = toSignal(
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
{ initialValue: '' }
);
readonly title = computed(() => {
const filters = this.activeTypeFilters();
if (filters.length === 1) {
if (filters[0] === 'm3u') {
return 'M3U Playlists';
}
if (filters[0] === 'xtream') {
return 'Xtream Playlists';
}
if (filters[0] === 'stalker') {
return 'Stalker Playlists';
}
}
return 'All Playlists';
});
readonly visibleSourcesCount = computed(() => {
const query = this.searchQuery().trim().toLowerCase();
const filters = this.activeTypeFilters();
const allPlaylists = this.playlists();
return allPlaylists
.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 || isXtreamFilter || isM3uFilter;
})
.filter((item) => item.title.toLowerCase().includes(query)).length;
});
readonly subtitle = computed(() => {
const count = this.visibleSourcesCount();
return `${count} ${count === 1 ? 'playlist' : 'playlists'}`;
});
onAddPlaylist(playlistType: PlaylistType): void {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type: playlistType },
}
);
}
}
+1
View File
@@ -174,6 +174,7 @@ declare global {
playlistId: string
) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetGlobalFavorites: () => Promise<any[]>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
dbAddRecentItem: (