refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 20:28:31 +01:00
1 parent 9834c9fc0d
commit 0ad1eeb785
193 files changed
+1908 -3370

No files matched your search

@@ -0,0 +1,5 @@
export * from './lib/global-favorites/global-favorites-page.component';
export * from './lib/workspace-context-panel/settings-context.service';
export * from './lib/workspace-shell/workspace-shell-actions';
export * from './lib/workspace-shell/workspace-shell.component';
export * from './lib/workspace-sources/workspace-sources.component';
@@ -0,0 +1,47 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
placeholder="Search favorites..."
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
/>
</mat-form-field>
</div>
<div class="nav-list" cdkDropList (cdkDropListDropped)="onDrop($event)">
@if (enrichedChannels().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">star_border</mat-icon>
@if (searchTerm()) {
<p class="empty-title">No results</p>
<p class="empty-hint">Try a different search term</p>
} @else {
<p class="empty-title">No live TV favorites yet</p>
<p class="empty-hint">
Star live channels from M3U, Xtream, or Stalker playlists to
see them here.
</p>
}
</div>
}
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
<app-channel-list-item
[isDraggable]="!searchTerm()"
[logo]="ch.logo"
[name]="ch.name"
[selected]="activeUid() === ch.uid"
[showEpg]="true"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[showFavoriteButton]="true"
[isFavorite]="true"
[showProgramInfoButton]="true"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
/>
}
</div>
@@ -0,0 +1,81 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
// ─── Search ────────────────────────────────────────────────────────────────────
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
::ng-deep {
.search-bar div {
border-radius: 20px;
}
div[matformfieldlineripple],
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
// ─── List ──────────────────────────────────────────────────────────────────────
.nav-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
// ─── Empty state ───────────────────────────────────────────────────────────────
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 14px;
font-weight: 500;
margin: 0 0 8px;
}
.empty-hint {
font-size: 12px;
opacity: 0.7;
margin: 0;
max-width: 260px;
}
}
@@ -0,0 +1,104 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
currentEpgProgram: EpgProgram | null;
progressPercentage: number;
}
@Component({
selector: 'app-global-favorites-list',
templateUrl: './global-favorites-list.component.html',
styleUrl: './global-favorites-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
DragDropModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
],
})
export class GlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input<number>(0);
readonly activeUid = input<string | null>(null);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly searchTerm = signal('');
readonly enrichedChannels = computed((): EnrichedUnifiedFavorite[] => {
const channels = this.channels();
const epgMap = this.epgMap();
const term = this.searchTerm().toLowerCase();
// Read progressTick to trigger periodic recalculation
this.progressTick();
const filtered = term
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
: channels;
return filtered.map((ch) => {
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
const currentEpgProgram = epgKey
? (epgMap.get(epgKey) ?? null)
: null;
return {
...ch,
currentEpgProgram,
progressPercentage: this.calcProgress(currentEpgProgram),
};
});
});
onChannelClick(channel: UnifiedFavoriteChannel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
this.favoriteToggled.emit(channel);
}
onDrop(event: CdkDragDrop<EnrichedUnifiedFavorite[]>): void {
// Disable reorder while searching to avoid index confusion
if (this.searchTerm()) return;
const list = [...this.channels()];
moveItemInArray(list, event.previousIndex, event.currentIndex);
this.channelsReordered.emit(list);
}
trackByUid(_: number, ch: EnrichedUnifiedFavorite): string {
return ch.uid;
}
private calcProgress(program: EpgProgram | null | undefined): number {
if (!program) return 0;
const now = Date.now();
const start = new Date(program.start).getTime();
const stop = new Date(program.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
}
@@ -0,0 +1,117 @@
<div class="global-favorites-page">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="40" />
</div>
} @else {
<div
[class]="
activePlayback()
? isEmbeddedPlayer()
? 'itv-container'
: 'itv-container--external'
: 'itv-container-no-player'
"
>
<!-- Sidebar: resizable channel list -->
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="360"
storageKey="global-favorites-sidebar-width"
>
<div class="sidebar-header">
<span class="sidebar-title">Global Favorites</span>
<span class="sidebar-subtitle"
>Live TV from all playlists</span
>
</div>
<app-global-favorites-list
[channels]="channels()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
@if (activePlayback()) {
<!-- Video player (embedded players only) -->
@if (isEmbeddedPlayer()) {
<div class="video-player">
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (player() === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src: currentStreamUrl(),
type: 'application/x-mpegURL',
},
],
}"
[volume]="1"
/>
}
@if (player() === 'html5') {
<app-html-video-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
@if (player() === 'artplayer') {
<app-art-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
</div>
}
<!-- EPG panel: spans full height for external players -->
<div class="epg">
@if (!isEmbeddedPlayer()) {
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
<div class="epg-content">
<app-epg-view [epgItems]="currentEpgItems()" />
</div>
</div>
} @else {
<div class="no-channel-selected">
<mat-icon>live_tv</mat-icon>
<p>Select a channel to start watching</p>
</div>
}
</div>
}
</div>
@@ -0,0 +1,186 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.global-favorites-page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex: 1;
}
// ─── Grid layouts ──────────────────────────────────────────────────────────────
// With embedded player: sidebar | video-player
// sidebar | epg
.itv-container {
display: grid;
grid-template-columns: auto 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'sidebar video-player video-player'
'sidebar epg epg';
height: 100%;
}
// With external player (MPV/VLC): sidebar | epg full height
.itv-container--external {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'sidebar epg';
height: 100%;
.epg {
border-top: none;
}
}
// Without player: full-width sidebar + center prompt
.itv-container-no-player {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'sidebar main';
height: 100%;
}
// ─── Sidebar ───────────────────────────────────────────────────────────────────
.sidebar {
grid-area: sidebar;
width: 360px;
min-width: 250px;
max-width: 600px;
flex-shrink: 0;
position: relative;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px 6px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 2px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.sidebar-title {
font-size: 15px;
font-weight: 600;
color: var(--mat-sys-on-surface);
line-height: 1.3;
}
.sidebar-subtitle {
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.8;
}
// ─── Video player ──────────────────────────────────────────────────────────────
.video-player {
grid-area: video-player;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.player-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 16px;
flex-shrink: 0;
background: var(--mat-sys-surface-container-low);
border-bottom: 1px solid var(--mat-sys-outline-variant);
min-height: 40px;
}
.player-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--mat-sys-on-surface);
}
.close-btn {
flex-shrink: 0;
}
.video-player > app-vjs-player,
.video-player > app-html-video-player,
.video-player > app-art-player {
flex: 1;
min-height: 0;
display: block;
}
// ─── EPG panel ─────────────────────────────────────────────────────────────────
.epg {
grid-area: epg;
min-height: 0;
overflow: hidden;
border-top: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
}
.epg-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
}
// ─── No channel selected ───────────────────────────────────────────────────────
.no-channel-selected {
grid-area: main;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
font-size: 14px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
p {
margin: 0;
}
}
@@ -0,0 +1,469 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
OnInit,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
ArtPlayerComponent,
HtmlVideoPlayerComponent,
ResizableDirective,
VjsPlayerComponent,
} from 'components';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { firstValueFrom } from 'rxjs';
import {
DataService,
PlaylistsService,
SettingsStore,
StalkerSessionService,
} from 'services';
import {
Channel,
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
import { GlobalFavoritesListComponent } from './global-favorites-list.component';
import { GlobalFavoritesService } from './global-favorites.service';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
@Component({
selector: 'app-global-favorites-page',
templateUrl: './global-favorites-page.component.html',
styleUrl: './global-favorites-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ArtPlayerComponent,
EpgViewComponent,
GlobalFavoritesListComponent,
HtmlVideoPlayerComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
ResizableDirective,
VjsPlayerComponent,
],
})
export class GlobalFavoritesPageComponent implements OnInit {
private readonly favoritesService = inject(GlobalFavoritesService);
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamApi = inject(XtreamApiService);
private readonly xtreamUrl = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly destroyRef = inject(DestroyRef);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly player = this.settingsStore.player;
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly isLoading = signal(true);
readonly channels = signal<UnifiedFavoriteChannel[]>([]);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly activeUid = signal<string | null>(null);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
readonly currentEpgItems = signal<EpgItem[]>([]);
readonly currentStreamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
/** Active channel as minimal Channel for InfoOverlayComponent */
/** Minimal Channel object needed by html5/artplayer components */
readonly activeChannelForOverlay = computed((): Channel | undefined => {
const p = this.activePlayback();
if (!p) return undefined;
return {
id: this.activeUid() ?? '',
name: p.title ?? '',
url: this.currentStreamUrl(),
tvg: {
logo: p.thumbnail ?? '',
id: '',
name: '',
rec: '',
url: '',
},
group: { title: '' },
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
} as Channel;
});
private tickInterval: ReturnType<typeof setInterval> | null = null;
ngOnInit(): void {
this.loadFavorites();
this.tickInterval = setInterval(() => {
this.progressTick.update((t) => t + 1);
}, 30_000);
this.destroyRef.onDestroy(() => {
if (this.tickInterval) {
clearInterval(this.tickInterval);
}
});
}
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
if (this.activeUid() === channel.uid) {
// Deselect
this.activeUid.set(null);
this.activePlayback.set(null);
this.currentEpgItems.set([]);
return;
}
this.activeUid.set(channel.uid);
this.currentEpgItems.set([]);
try {
const playback = await this.resolvePlayback(channel);
this.activePlayback.set(playback);
if (!this.portalPlayer.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback);
}
this.loadEpgItemsForChannel(channel).then((items) => {
this.currentEpgItems.set(items);
});
} catch {
this.activePlayback.set(null);
this.activeUid.set(null);
}
}
onClose(): void {
this.activePlayback.set(null);
this.activeUid.set(null);
this.currentEpgItems.set([]);
}
async onFavoriteToggled(channel: UnifiedFavoriteChannel): Promise<void> {
await this.favoritesService.removeFavorite(channel);
this.channels.update((chs) => chs.filter((c) => c.uid !== channel.uid));
if (this.activeUid() === channel.uid) {
this.activePlayback.set(null);
this.activeUid.set(null);
this.currentEpgItems.set([]);
}
}
async onReorder(reordered: UnifiedFavoriteChannel[]): Promise<void> {
this.channels.set(reordered);
await this.favoritesService.reorder(reordered);
}
// ─── Private ────────────────────────────────────────────────────────────
private async loadFavorites(): Promise<void> {
this.isLoading.set(true);
try {
const channels =
await this.favoritesService.getUnifiedLiveFavorites();
this.channels.set(channels);
// Load EPG in background without blocking
this.loadEpgForChannels(channels);
} catch {
this.channels.set([]);
} finally {
this.isLoading.set(false);
}
}
private async loadEpgForChannels(
channels: UnifiedFavoriteChannel[]
): Promise<void> {
const epgMap = new Map<string, EpgProgram | null>(this.epgMap());
const now = Date.now();
// Group xtream channels by playlistId to batch credential lookups
const xtreamByPlaylist = new Map<string, UnifiedFavoriteChannel[]>();
for (const ch of channels) {
if (ch.sourceType === 'xtream') {
const list = xtreamByPlaylist.get(ch.playlistId) ?? [];
list.push(ch);
xtreamByPlaylist.set(ch.playlistId, list);
}
}
const tasks: Promise<void>[] = [];
// M3U EPG
for (const ch of channels) {
if (ch.sourceType !== 'm3u' || !ch.tvgId) continue;
tasks.push(this.loadM3uEpg(ch, epgMap, now));
}
// Xtream EPG – one credential lookup per playlist
for (const [playlistId, chList] of xtreamByPlaylist.entries()) {
tasks.push(
this.loadXtreamEpgForPlaylist(playlistId, chList, epgMap, now)
);
}
await Promise.all(tasks.map((t) => t.catch(() => null)));
this.epgMap.set(new Map(epgMap));
}
private async loadM3uEpg(
ch: UnifiedFavoriteChannel,
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
if (!window.electron?.getChannelPrograms || !ch.tvgId) return;
try {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(ch.tvgId);
const current =
programs.find(
(p) =>
new Date(p.start).getTime() <= now &&
now < new Date(p.stop).getTime()
) ?? null;
epgMap.set(ch.tvgId.trim(), current);
} catch {
epgMap.set(ch.tvgId.trim(), null);
}
}
private async loadXtreamEpgForPlaylist(
playlistId: string,
channels: UnifiedFavoriteChannel[],
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
let playlist: any;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
);
} catch {
return;
}
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
await Promise.all(
channels
.map(async (ch) => {
if (!ch.xtreamId) return;
try {
const items: EpgItem[] =
await this.xtreamApi.getShortEpg(
credentials,
ch.xtreamId,
2
);
const nowSec = Math.floor(now / 1000);
const current =
items.find(
(p) =>
Number(p.start_timestamp) <= nowSec &&
nowSec < Number(p.stop_timestamp)
) ?? null;
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
if (epgKey) {
epgMap.set(
epgKey,
current
? {
start: new Date(
Number(current.start_timestamp) *
1000
).toISOString(),
stop: new Date(
Number(current.stop_timestamp) *
1000
).toISOString(),
channel: String(ch.xtreamId),
title: current.title,
desc: current.description ?? null,
category: null,
}
: null
);
}
} catch {
// skip
}
})
.map((t) => t.catch(() => null))
);
}
private async resolvePlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
switch (ch.sourceType) {
case 'm3u':
return this.resolveM3uPlayback(ch);
case 'xtream':
return this.resolveXtreamPlayback(ch);
case 'stalker':
return this.resolveStalkerPlayback(ch);
}
}
private async loadEpgItemsForChannel(
ch: UnifiedFavoriteChannel
): Promise<EpgItem[]> {
try {
if (
ch.sourceType === 'm3u' &&
ch.tvgId &&
window.electron?.getChannelPrograms
) {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(ch.tvgId);
return programs.map((p, i) => ({
id: String(i),
epg_id: p.channel,
title: p.title,
lang: 'en',
start: p.start,
end: p.stop,
stop: p.stop,
description: p.desc ?? '',
channel_id: p.channel,
start_timestamp: String(
Math.floor(new Date(p.start).getTime() / 1000)
),
stop_timestamp: String(
Math.floor(new Date(p.stop).getTime() / 1000)
),
}));
}
if (ch.sourceType === 'xtream' && ch.xtreamId) {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
);
return await this.xtreamApi.getShortEpg(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
ch.xtreamId,
10
);
}
} catch {
// ignore
}
return [];
}
private resolveM3uPlayback(
ch: UnifiedFavoriteChannel
): ResolvedPortalPlayback {
return {
streamUrl: ch.streamUrl ?? '',
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private async resolveXtreamPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
);
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
const streamUrl = this.xtreamUrl.constructLiveUrl(
credentials,
ch.xtreamId!
);
return {
streamUrl,
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private async resolveStalkerPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
);
const portalUrl =
ch.stalkerPortalUrl ?? playlist.portalUrl ?? playlist.url;
const macAddress = ch.stalkerMacAddress ?? playlist.macAddress;
const cmd = ch.stalkerCmd ?? '';
const params = {
action: StalkerPortalActions.CreateLink,
cmd,
type: 'itv' as const,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response = await this.stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
});
}
const rawCmd = response?.js?.cmd ?? '';
const streamUrl = this.normalizeStalkerCmd(rawCmd);
return {
streamUrl,
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private normalizeStalkerCmd(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://')
) {
return candidate;
}
}
return trimmed;
}
}
@@ -0,0 +1,321 @@
/**
* GlobalFavoritesService
*
* Aggregates live TV favorites from all three playlist source types:
* - M3U – channels whose URL/id is listed in playlist.favorites[]
* - Xtream – rows in the `favorites` DB table where content.type = 'live'
* - Stalker – items in playlist.favorites[] that have a `cmd` (live channels)
* and do NOT have movie_id / series_id
*
* Also handles reordering: persists position for Xtream rows via IPC.
* For M3U and Stalker the list order is managed in the app_state table
* as a JSON array of UIDs (keyed by GLOBAL_FAVORITES_ORDER_KEY).
*/
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Channel, PlaylistMeta } from 'shared-interfaces';
import {
buildFavoriteUid,
UnifiedFavoriteChannel,
} from './unified-favorite-channel.interface';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@Injectable({ providedIn: 'root' })
export class GlobalFavoritesService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
/**
* Fetch and merge live TV favorites from all source types.
* The returned list is sorted by persisted position first,
* then by addedAt descending for items without a position.
*/
async getUnifiedLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
const [m3uItems, xtreamItems, stalkerItems, savedOrder] =
await Promise.all([
this.getM3uFavorites(),
this.getXtreamLiveFavorites(),
this.getStalkerLiveFavorites(),
this.getSavedOrder(),
]);
const all = [...m3uItems, ...xtreamItems, ...stalkerItems];
return this.applyOrder(all, savedOrder);
}
/**
* Remove a channel from its source playlist's favorites.
* Works for all three source types (M3U, Xtream, Stalker).
*/
async removeFavorite(channel: UnifiedFavoriteChannel): Promise<void> {
switch (channel.sourceType) {
case 'm3u': {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(channel.playlistId)
);
const currentFavs = (playlist.favorites as string[]) ?? [];
const filtered = currentFavs.filter(
(f) => f !== channel.streamUrl
);
await firstValueFrom(
this.playlistsService.setFavorites(
channel.playlistId,
filtered
)
);
break;
}
case 'xtream': {
if (window.electron && channel.contentId != null) {
await window.electron.dbRemoveFavorite(
channel.contentId,
channel.playlistId
);
}
break;
}
case 'stalker': {
const sourceItemId = channel.uid.split('::')[2];
await firstValueFrom(
this.playlistsService.removeFromPortalFavorites(
channel.playlistId,
sourceItemId
)
);
break;
}
}
}
/**
* Persist the new display order.
* For Xtream items update the DB position column.
* For all items, persist the UID order in app_state.
*/
async reorder(channels: UnifiedFavoriteChannel[]): Promise<void> {
const uidOrder = channels.map((ch) => ch.uid);
// Persist xtream positions to DB
const xtreamUpdates = channels
.filter((ch) => ch.sourceType === 'xtream' && ch.contentId != null)
.map((ch, index) => ({
content_id: ch.contentId!,
position: index,
}));
await Promise.all([
xtreamUpdates.length > 0 && window.electron
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(uidOrder),
]);
}
// ─── Private helpers ───────────────────────────────────────────
private async getM3uFavorites(): Promise<UnifiedFavoriteChannel[]> {
const allMeta = await firstValueFrom(
this.store
.select(selectAllPlaylistsMeta)
.pipe(map((metas) => metas as PlaylistMeta[]))
);
const m3uPlaylists = allMeta.filter(
(p) => p._id && !p.serverUrl && !p.macAddress
);
const results: UnifiedFavoriteChannel[] = [];
for (const meta of m3uPlaylists) {
if (!meta.favorites?.length) continue;
const favoriteIds = new Set(
(meta.favorites as string[]).map(String)
);
// Load full playlist to access channel items
let playlist: any;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
);
} catch {
continue;
}
const items: Channel[] = playlist?.playlist?.items ?? [];
for (const ch of items) {
if (favoriteIds.has(ch.id) || favoriteIds.has(ch.url)) {
const sourceItemId = ch.url || ch.id;
results.push({
uid: buildFavoriteUid('m3u', meta._id, sourceItemId),
name: ch.name,
logo: ch.tvg?.logo ?? null,
sourceType: 'm3u',
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'M3U',
streamUrl: ch.url,
tvgId: ch.tvg?.id || ch.tvg?.name || ch.name,
addedAt: new Date(0).toISOString(),
position: 0,
});
}
}
}
return results;
}
private async getXtreamLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
if (!window.electron) return [];
let items: any[] = [];
try {
items = await this.dbService.getGlobalFavorites();
} catch {
return [];
}
// getGlobalFavorites now only returns type='live'
return items.map((item) => ({
uid: buildFavoriteUid('xtream', item.playlist_id, item.xtream_id),
name: item.title,
logo: item.poster_url ?? null,
sourceType: 'xtream' as const,
playlistId: item.playlist_id,
playlistName: item.playlist_name,
xtreamId: item.xtream_id,
tvgId: String(item.xtream_id),
addedAt: item.added_at ?? new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.id,
}));
}
private async getStalkerLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
const allMeta = await firstValueFrom(
this.store
.select(selectAllPlaylistsMeta)
.pipe(map((metas) => metas as PlaylistMeta[]))
);
const stalkerPlaylists = allMeta.filter((p) => p._id && p.macAddress);
const results: UnifiedFavoriteChannel[] = [];
for (const meta of stalkerPlaylists) {
if (!meta.favorites?.length) continue;
// Fetch full playlist to get credentials
let playlist: any;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
);
} catch {
continue;
}
const favorites = Array.isArray(playlist?.favorites)
? playlist.favorites
: [];
for (const fav of favorites) {
// Live channels: have a cmd, no movie_id / series_id
const isLive =
fav.cmd &&
!fav.movie_id &&
!fav.series_id &&
(fav.stream_type === 'live' ||
fav.category_id === 'itv' ||
(!fav.stream_type && !fav.series_id && !fav.movie_id));
if (!isLive) continue;
const streamId = fav.stream_id ?? fav.id;
results.push({
uid: buildFavoriteUid('stalker', meta._id, streamId),
name: fav.o_name || fav.name || 'Unknown',
logo: fav.logo ?? fav.cover ?? null,
sourceType: 'stalker' as const,
playlistId: meta._id,
playlistName:
meta.title || meta.filename || 'Stalker Portal',
stalkerCmd: fav.cmd,
stalkerPortalUrl: playlist.portalUrl ?? playlist.url,
stalkerMacAddress: playlist.macAddress,
addedAt: fav.added_at
? new Date(fav.added_at).toISOString()
: new Date(0).toISOString(),
position: 0,
});
}
}
return results;
}
private async getSavedOrder(): Promise<string[]> {
if (!window.electron?.dbGetAppState) return [];
try {
const raw = await window.electron.dbGetAppState(
GLOBAL_FAVORITES_ORDER_KEY
);
if (!raw) return [];
return JSON.parse(raw) as string[];
} catch {
return [];
}
}
private async saveOrder(uidOrder: string[]): Promise<void> {
if (!window.electron?.dbSetAppState) return;
try {
await window.electron.dbSetAppState(
GLOBAL_FAVORITES_ORDER_KEY,
JSON.stringify(uidOrder)
);
} catch {
// ignore
}
}
private applyOrder(
channels: UnifiedFavoriteChannel[],
savedOrder: string[]
): UnifiedFavoriteChannel[] {
if (!savedOrder.length) {
// Default: sort by addedAt descending, xtream position asc
return channels.slice().sort((a, b) => {
if (a.position !== b.position) return a.position - b.position;
return (
new Date(b.addedAt).getTime() -
new Date(a.addedAt).getTime()
);
});
}
const orderMap = new Map(savedOrder.map((uid, i) => [uid, i]));
const ordered: UnifiedFavoriteChannel[] = [];
const unordered: UnifiedFavoriteChannel[] = [];
for (const ch of channels) {
if (orderMap.has(ch.uid)) {
ordered.push({ ...ch, position: orderMap.get(ch.uid)! });
} else {
unordered.push(ch);
}
}
ordered.sort((a, b) => a.position - b.position);
unordered.sort(
(a, b) =>
new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()
);
return [...ordered, ...unordered];
}
}
@@ -0,0 +1,73 @@
/**
* Unified interface for displaying live TV favorites from any source
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
*/
export type FavoriteSourceType = 'm3u' | 'xtream' | 'stalker';
export interface UnifiedFavoriteChannel {
/**
* Unique identifier within the global-favorites list.
* Format: `{sourceType}::{playlistId}::{sourceItemId}`
*/
uid: string;
/** Display name of the channel */
name: string;
/** Logo / thumbnail URL */
logo: string | null;
/** Source type – determines how to resolve the stream URL */
sourceType: FavoriteSourceType;
/** ID of the parent playlist */
playlistId: string;
/** Human-readable playlist name */
playlistName: string;
/** Direct stream URL (M3U channels only) */
streamUrl?: string;
/** Xtream numeric stream ID (Xtream channels only) */
xtreamId?: number;
/**
* TVG id for M3U EPG lookup.
* For Xtream channels, also used for short-EPG lookup.
*/
tvgId?: string;
/**
* The stalker cmd value needed to resolve the stream URL.
* Stored for Stalker favorites that are live channels.
*/
stalkerCmd?: string;
/** ISO timestamp when added to favorites */
addedAt: string;
/**
* Display order within the unified list.
* Lower values appear first. 0 = unset (sort by addedAt desc).
*/
position: number;
/** Xtream DB content id — used for REORDER IPC call */
contentId?: number;
/** Stalker portal credentials needed to resolve the live stream */
stalkerPortalUrl?: string;
stalkerMacAddress?: string;
}
/**
* Build a stable UID string for a unified favorite channel.
*/
export function buildFavoriteUid(
sourceType: FavoriteSourceType,
playlistId: string,
sourceItemId: string | number
): string {
return `${sourceType}::${playlistId}::${sourceItemId}`;
}
@@ -0,0 +1,52 @@
<div class="palette-shell">
<header class="palette-header">
<h2>Command palette</h2>
<p>Global and contextual actions</p>
</header>
<label class="palette-search">
<mat-icon>search</mat-icon>
<input
#queryInput
type="search"
spellcheck="false"
[value]="query()"
(input)="onQueryInput($event)"
(keydown)="onInputKeydown($event)"
placeholder="Type to filter commands"
aria-label="Filter commands"
/>
<span class="palette-search__hint">Ctrl/Cmd+K</span>
</label>
<div class="palette-results">
@if (commandGroups().length === 0) {
<div class="palette-empty">No commands found</div>
} @else {
@for (group of commandGroups(); track group.scope) {
<section class="palette-group">
<h3>{{ group.title }}</h3>
<div class="palette-group__items">
@for (command of group.items; track command.id) {
<button
type="button"
class="palette-command"
[class.is-selected]="isCommandSelected(command)"
[disabled]="!command.enabled"
(mouseenter)="onCommandHover(command)"
(click)="onCommandClick(command)"
>
<span class="palette-command__label">{{
command.label
}}</span>
<span class="palette-command__description">{{
command.description
}}</span>
</button>
}
</div>
</section>
}
}
</div>
</div>
@@ -0,0 +1,160 @@
:host {
display: block;
width: 100%;
max-height: 80vh;
app-region: no-drag;
}
.palette-shell {
padding: 14px;
background: var(--app-header-bg, var(--mat-sys-surface-container-low));
color: var(--mat-sys-on-surface);
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 14px;
box-shadow:
0 24px 48px -28px rgba(0, 0, 0, 0.5),
0 10px 24px -18px rgba(0, 0, 0, 0.35);
}
.palette-header {
display: grid;
gap: 2px;
margin-bottom: 10px;
h2 {
margin: 0;
font-size: 1rem;
letter-spacing: -0.01em;
font-weight: 640;
}
p {
margin: 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
}
}
.palette-search {
display: flex;
align-items: center;
gap: 8px;
border-radius: 10px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container);
min-height: 38px;
padding: 0 10px;
&:focus-within {
border-color: var(--mat-sys-primary);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
input {
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
font-size: 0.9rem;
width: 100%;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
}
}
}
.palette-search__hint {
font-size: 0.72rem;
color: var(--mat-sys-on-surface-variant);
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 6px;
padding: 2px 6px;
line-height: 1;
flex-shrink: 0;
}
.palette-results {
max-height: min(56vh, 520px);
overflow: auto;
margin-top: 12px;
display: grid;
gap: 10px;
padding-right: 2px;
}
.palette-group {
display: grid;
gap: 6px;
h3 {
margin: 0;
font-size: 0.74rem;
color: var(--mat-sys-on-surface-variant);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 620;
}
}
.palette-group__items {
display: grid;
gap: 4px;
}
.palette-command {
display: grid;
gap: 1px;
width: 100%;
text-align: left;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: inherit;
padding: 8px 10px;
cursor: pointer;
&:hover:not(:disabled),
&.is-selected:not(:disabled) {
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.palette-command__label {
font-size: 0.9rem;
font-weight: 560;
letter-spacing: -0.006em;
}
.palette-command__description {
font-size: 0.76rem;
color: var(--mat-sys-on-surface-variant);
}
.palette-empty {
border: 1px dashed var(--mat-sys-outline-variant);
border-radius: 10px;
padding: 18px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.88rem;
}
@@ -0,0 +1,66 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { WorkspaceCommandPaletteComponent } from './workspace-command-palette.component';
describe('WorkspaceCommandPaletteComponent', () => {
let fixture: ComponentFixture<WorkspaceCommandPaletteComponent>;
let component: WorkspaceCommandPaletteComponent;
let dialogRef: { close: jest.Mock };
beforeEach(async () => {
dialogRef = {
close: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [WorkspaceCommandPaletteComponent],
providers: [
{
provide: MatDialogRef,
useValue: dialogRef,
},
{
provide: MAT_DIALOG_DATA,
useValue: {
query: 'recent',
commands: [
{
id: 'global-search',
label: 'Search all Xtream playlists',
description: 'Open global search overlay',
scope: 'global',
enabled: true,
},
{
id: 'playlist-search',
label: 'Search this playlist',
description: 'Open playlist search route',
scope: 'playlist',
enabled: true,
},
],
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(WorkspaceCommandPaletteComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('creates and applies initial query', () => {
expect(component).toBeTruthy();
expect(component.query()).toBe('recent');
});
it('closes with selected command and query on click', () => {
const command = component.flatCommands()[0];
component.onCommandClick(command);
expect(dialogRef.close).toHaveBeenCalledWith({
commandId: 'global-search',
query: 'recent',
});
});
});
@@ -0,0 +1,222 @@
import {
AfterViewInit,
Component,
ElementRef,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
export type WorkspaceCommandScope = 'global' | 'playlist' | 'section';
export type WorkspaceCommandId =
| 'global-search'
| 'playlist-search'
| 'open-global-favorites'
| 'open-downloads'
| 'open-global-recent';
export interface WorkspaceCommandItem {
id: WorkspaceCommandId;
label: string;
description: string;
scope: WorkspaceCommandScope;
enabled: boolean;
}
export interface WorkspaceCommandSelection {
commandId: WorkspaceCommandId;
query: string;
}
interface WorkspaceCommandPaletteData {
commands: WorkspaceCommandItem[];
query?: string;
}
interface WorkspaceCommandGroup {
scope: WorkspaceCommandScope;
title: string;
items: WorkspaceCommandItem[];
}
@Component({
selector: 'app-workspace-command-palette',
imports: [MatDialogModule, MatIconModule],
templateUrl: './workspace-command-palette.component.html',
styleUrl: './workspace-command-palette.component.scss',
})
export class WorkspaceCommandPaletteComponent implements AfterViewInit {
private readonly dialogRef = inject(
MatDialogRef<
WorkspaceCommandPaletteComponent,
WorkspaceCommandSelection | undefined
>
);
private readonly data =
inject<WorkspaceCommandPaletteData>(MAT_DIALOG_DATA);
private readonly queryInputRef =
viewChild<ElementRef<HTMLInputElement>>('queryInput');
readonly query = signal(this.data?.query ?? '');
readonly selectedIndex = signal(0);
readonly filteredCommands = computed(() => {
const term = this.query().trim().toLowerCase();
if (!term) {
return this.data.commands;
}
return this.data.commands.filter((command) => {
const haystack =
`${command.label} ${command.description}`.toLowerCase();
return haystack.includes(term);
});
});
readonly commandGroups = computed<WorkspaceCommandGroup[]>(() => {
const commands = this.filteredCommands();
const grouped: WorkspaceCommandGroup[] = [];
const buildGroup = (
scope: WorkspaceCommandScope,
title: string
): WorkspaceCommandGroup | null => {
const items = commands.filter((command) => command.scope === scope);
if (items.length === 0) {
return null;
}
return { scope, title, items };
};
const groups = [
buildGroup('global', 'Global actions'),
buildGroup('playlist', 'This playlist'),
buildGroup('section', 'This section'),
].filter((group): group is WorkspaceCommandGroup => group !== null);
grouped.push(...groups);
return grouped;
});
readonly flatCommands = computed(() =>
this.commandGroups().reduce<WorkspaceCommandItem[]>(
(items, group) => items.concat(group.items),
[]
)
);
constructor() {
effect(() => {
const items = this.flatCommands();
if (items.length === 0) {
this.selectedIndex.set(-1);
return;
}
const currentIndex = this.selectedIndex();
if (currentIndex < 0 || currentIndex >= items.length) {
this.selectedIndex.set(0);
}
});
}
ngAfterViewInit(): void {
queueMicrotask(() => {
this.queryInputRef()?.nativeElement.focus();
this.queryInputRef()?.nativeElement.select();
});
}
onQueryInput(event: Event): void {
const target = event.target as HTMLInputElement | null;
this.query.set(target?.value ?? '');
}
onInputKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown') {
event.preventDefault();
this.moveSelection(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.moveSelection(-1);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.selectCurrent();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
this.dialogRef.close();
}
}
onCommandHover(command: WorkspaceCommandItem): void {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
if (index >= 0) {
this.selectedIndex.set(index);
}
}
onCommandClick(command: WorkspaceCommandItem): void {
if (!command.enabled) {
return;
}
this.dialogRef.close({
commandId: command.id,
query: this.query().trim(),
});
}
isCommandSelected(command: WorkspaceCommandItem): boolean {
const index = this.flatCommands().findIndex(
(item) => item.id === command.id
);
return index >= 0 && this.selectedIndex() === index;
}
private moveSelection(direction: 1 | -1): void {
const items = this.flatCommands();
if (items.length === 0) {
return;
}
const currentIndex = this.selectedIndex();
const safeIndex = currentIndex < 0 ? 0 : currentIndex;
const nextIndex = (safeIndex + direction + items.length) % items.length;
this.selectedIndex.set(nextIndex);
}
private selectCurrent(): void {
const items = this.flatCommands();
if (items.length === 0) {
return;
}
const index = this.selectedIndex();
if (index < 0 || index >= items.length) {
return;
}
this.onCommandClick(items[index]);
}
}
@@ -0,0 +1,37 @@
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');
readonly pendingScrollTarget = signal<string | null>(null);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
}
setActiveSection(id: string): void {
this.activeSection.set(id);
}
navigateToSection(id: string): void {
this.activeSection.set(id);
this.pendingScrollTarget.set(id);
}
clearPendingScrollTarget(): void {
this.pendingScrollTarget.set(null);
}
reset(): void {
this.sections.set([]);
this.activeSection.set('general');
this.pendingScrollTarget.set(null);
}
}
@@ -0,0 +1,61 @@
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>{{ title() }}</h2>
@if (isXtreamCategories()) {
<button
type="button"
mat-icon-button
class="context-header__action"
(click)="openManageCategories()"
matTooltip="Manage categories"
aria-label="Manage categories"
>
<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()) {
<div class="context-skeleton" aria-hidden="true">
@for (row of skeletonRows; track row) {
<div class="context-skeleton__row">
<span class="context-skeleton__dot"></span>
<span
class="context-skeleton__label"
[style.width.%]="skeletonLabelWidths[row]"
></span>
<span class="context-skeleton__count"></span>
</div>
}
</div>
} @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,176 @@
: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);
}
}
.context-header__action {
width: 28px;
height: 28px;
line-height: 28px;
flex-shrink: 0;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
}
.context-header__badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 5px;
padding: 2px 0;
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;
}
.context-skeleton {
display: grid;
gap: 8px;
padding: 4px 6px 2px;
overflow: hidden;
}
.context-skeleton__row {
display: grid;
grid-template-columns: 12px minmax(0, 1fr) 28px;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 10px;
border-radius: 12px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
transparent
);
}
.context-skeleton__dot,
.context-skeleton__label,
.context-skeleton__count {
position: relative;
overflow: hidden;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%,
var(--app-body-color, var(--mat-sys-on-surface-variant)) 48%
);
&::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 8%,
color-mix(
in srgb,
var(--app-heading-color, var(--mat-sys-on-surface)) 18%,
transparent
)
44%,
transparent 74%
);
transform: translateX(-120%);
animation: context-skeleton-shimmer 1.15s ease-in-out infinite;
}
}
.context-skeleton__dot {
width: 12px;
height: 12px;
border-radius: 999px;
}
.context-skeleton__label {
height: 9px;
border-radius: 999px;
}
.context-skeleton__count {
justify-self: end;
width: 22px;
height: 9px;
border-radius: 999px;
}
@keyframes context-skeleton-shimmer {
100% {
transform: translateX(120%);
}
}
@@ -0,0 +1,201 @@
import { Component, computed, DestroyRef, inject, input } 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 { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '@iptvnator/portal/xtream/feature';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
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,
PlaylistErrorViewComponent,
MatIconButton,
MatIcon,
MatTooltip,
],
templateUrl: './workspace-context-panel.component.html',
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceContextPanelComponent {
private readonly router = inject(Router);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
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 skeletonRows = Array.from({ length: 14 }, (_, index) => index);
readonly skeletonLabelWidths = [
78, 66, 74, 59, 83, 69, 76, 62, 81, 64, 72, 67, 79, 61,
];
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 '';
});
openManageCategories(): void {
const context = this.context();
const section = this.section();
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.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxHeight: '80vh',
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (result) {
this.xtreamStore.reloadCategories();
}
});
}
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,
]);
}
}
@@ -0,0 +1,63 @@
import { Component, computed, inject } from '@angular/core';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
/**
* 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,26 @@
@use '../../nav-list.scss';
: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;
}
@@ -0,0 +1,50 @@
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],
styleUrls: ['./workspace-settings-context-panel.component.scss'],
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;">
{{ 'SETTINGS.APP_PREFERENCES' | translate }}
</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.navigateToSection(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,18 @@
import { InjectionToken } from '@angular/core';
import { PlaylistType } from 'components';
export interface WorkspaceAccountInfoData {
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
}
export interface WorkspaceShellActions {
openAddPlaylistDialog(type: PlaylistType): void;
openGlobalSearch(initialQuery?: string): void;
openGlobalRecent(): void;
openAccountInfo(data: WorkspaceAccountInfoData): void;
}
export const WORKSPACE_SHELL_ACTIONS =
new InjectionToken<WorkspaceShellActions>('WORKSPACE_SHELL_ACTIONS');
@@ -0,0 +1,248 @@
<div class="workspace-shell">
<aside class="app-rail" [class.is-macos]="isMacOS">
<a
class="brand"
routerLink="/workspace/dashboard"
matTooltip="Dashboard (all playlists)"
[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
class="rail-shortcut"
routerLink="/workspace/settings"
[class.is-active]="isSettingsRoute()"
matTooltip="Settings (global)"
[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"
/>
<button
type="button"
class="command-trigger"
mat-icon-button
(click)="openCommandPalette()"
matTooltip="Command palette (Ctrl/Cmd+K)"
aria-label="Open command palette"
>
<mat-icon>keyboard_command_key</mat-icon>
</button>
</label>
<div class="header-actions">
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="addPlaylistMenu().menu()"
matTooltip="Add playlist (all playlists)"
aria-label="Add playlist"
>
<mat-icon>add</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<span class="header-actions-divider" aria-hidden="true"></span>
<a
class="header-shortcut"
mat-icon-button
routerLink="/workspace/global-favorites"
[class.is-active]="isGlobalFavoritesRoute()"
matTooltip="Global favorites"
aria-label="Open global favorites"
>
<mat-icon>star</mat-icon>
</a>
@if (isElectron) {
<button
type="button"
class="header-shortcut"
mat-icon-button
[class.is-active]="isDownloadsView()"
(click)="openDownloadsShortcut()"
matTooltip="Downloads"
aria-label="Open downloads"
>
<mat-icon>download</mat-icon>
</button>
}
@if (hasContextActions()) {
<span class="header-actions-divider" aria-hidden="true"></span>
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="contextActionsMenu"
matTooltip="Section actions"
aria-label="Open context actions"
>
<mat-icon>more_horiz</mat-icon>
</button>
}
</div>
<mat-menu #contextActionsMenu="matMenu">
@if (!hasContextActions()) {
<button mat-menu-item disabled>
<span>No actions for this section</span>
</button>
} @else {
@if (contextActionGroups().hasPlaylistActions) {
@if (canOpenPlaylistInfo()) {
<button mat-menu-item (click)="openPlaylistInfo()">
<mat-icon>playlist_play</mat-icon>
<span>Playlist info (this playlist)</span>
</button>
}
@if (canOpenAccountInfo()) {
<button mat-menu-item (click)="openAccountInfo()">
<mat-icon>person</mat-icon>
<span>Account info (this playlist)</span>
</button>
}
}
@if (
contextActionGroups().hasCleanupActions &&
contextActionGroups().hasPlaylistActions
) {
<mat-divider />
}
@if (headerBulkAction(); as action) {
<button
mat-menu-item
(click)="runHeaderBulkAction()"
[disabled]="action.disabled"
[attr.aria-label]="action.ariaLabel"
>
<mat-icon>{{ action.icon }}</mat-icon>
<span>{{ action.tooltip }}</span>
</button>
}
}
</mat-menu>
</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="sidebar-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="sidebar-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="sidebar-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="sidebar-width"
>
<app-workspace-favorites-context-panel />
</aside>
}
<main class="workspace-content">
<router-outlet />
</main>
</div>
@if (showExternalPlaybackBar()) {
@if (externalPlaybackSession(); as session) {
<footer class="workspace-now-playing">
<app-external-playback-dock
[session]="session"
(closeClicked)="closeActiveExternalSession()"
(dismissClicked)="dismissActiveExternalSession()"
/>
</footer>
}
}
</div>
@@ -0,0 +1,520 @@
: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 minmax(0, 1fr) auto;
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 3;
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: var(--app-selection-surface);
--rail-link-active-bg-strong: var(--app-selection-surface-strong);
--rail-link-active-color: var(--app-selection-color);
--rail-link-active-icon: var(--app-selection-color);
--rail-link-active-border: var(--app-selection-border);
--rail-link-active-glow: var(--app-selection-glow);
}
.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,
transform 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: linear-gradient(
145deg,
var(--rail-link-active-bg-strong),
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);
transform: translateY(-1px);
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: 44px;
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 24px;
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 color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
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;
}
}
.command-trigger {
width: 28px;
height: 28px;
padding: 0;
margin-right: -4px;
color: var(--mat-sys-on-surface-variant);
border-radius: 8px;
flex-shrink: 0;
&:hover {
background: var(--mat-sys-surface-container-high);
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
// ─── Header Actions ───────────────────────────────────────────────────────────
.header-actions {
display: flex;
align-items: center;
gap: 4px;
app-region: no-drag;
button,
a {
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(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 10px 20px -16px var(--app-selection-glow);
}
}
}
.header-actions-divider {
width: 1px;
height: 20px;
background: var(--mat-sys-outline-variant);
margin: 0 4px;
flex-shrink: 0;
opacity: 0.6;
}
// ─── 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;
}
}
.workspace-now-playing {
grid-column: 2;
grid-row: 3;
}
// ─── 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: var(--app-selection-border);
color: var(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
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);
}
// ─── Dialog Surfaces ──────────────────────────────────────────────────────────
:host ::ng-deep .workspace-command-palette-overlay .mat-mdc-dialog-surface {
background: transparent;
box-shadow: none;
overflow: visible;
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (max-width: 960px) {
.search-field {
min-width: 120px;
flex: 1 1 150px;
}
}
@media (max-width: 780px) {
.workspace-shell {
grid-template-columns: 56px 1fr;
grid-template-rows: 56px 1fr auto;
}
.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 minmax(0, 1fr) auto;
}
.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;
}
.workspace-now-playing {
grid-column: 1;
grid-row: 4;
}
}
@@ -0,0 +1,173 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import {
FavoritesContextService,
PORTAL_EXTERNAL_PLAYBACK,
} from '@iptvnator/portal/shared/util';
import { of } from 'rxjs';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
DownloadsService,
PlaylistsService,
SettingsStore,
} from 'services';
import { SettingsContextService } from '../workspace-context-panel/settings-context.service';
import { WorkspaceShellComponent } from './workspace-shell.component';
import { WORKSPACE_SHELL_ACTIONS } from './workspace-shell-actions';
class MockXtreamStore {
readonly contentSortMode = signal<
'date-desc' | 'date-asc' | 'name-asc' | 'name-desc'
>('date-desc');
readonly getCategoryItemCounts = signal(new Map<number, number>());
readonly recentItems = signal<any[]>([]);
setContentSortMode = jest.fn();
setSearchTerm = jest.fn();
setCategorySearchTerm = jest.fn();
reloadCategories = jest.fn();
}
describe('WorkspaceShellComponent action matrix', () => {
beforeEach(() => {
(window as any).electron = { platform: 'darwin' };
const selectSignal = jest
.fn()
.mockReturnValueOnce(signal('Playlist A'))
.mockReturnValueOnce(
signal({
_id: 'pl-1',
serverUrl: 'http://example.com',
title: 'Playlist A',
})
)
.mockReturnValueOnce(
signal([
{ _id: 'pl-1', serverUrl: 'http://example.com' },
{ _id: 'pl-2', serverUrl: 'http://example.com' },
])
);
TestBed.configureTestingModule({
providers: [
{
provide: Router,
useValue: {
url: '/workspace/xtreams/pl-1/vod',
events: of(
new NavigationEnd(
1,
'/workspace/xtreams/pl-1/vod',
'/workspace/xtreams/pl-1/vod'
)
),
navigate: jest.fn(),
navigateByUrl: jest.fn(),
parseUrl: jest
.fn()
.mockReturnValue({ queryParams: {} }),
createUrlTree: jest.fn(),
isActive: jest.fn(),
},
},
{
provide: Store,
useValue: {
selectSignal,
},
},
{
provide: XtreamStore,
useClass: MockXtreamStore,
},
{
provide: DownloadsService,
useValue: {
downloads: signal([]),
clearCompleted: jest.fn().mockResolvedValue(undefined),
loadDownloads: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
visibleSession: signal(null),
dismissActiveSession: jest.fn(),
closeSession: jest.fn(),
},
},
{
provide: SettingsStore,
useValue: {
showExternalPlaybackBar: signal(true),
},
},
{
provide: PlaylistsService,
useValue: {
clearPortalRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn().mockReturnValue({
afterClosed: () => of(undefined),
}),
},
},
{
provide: FavoritesContextService,
useValue: {},
},
{
provide: SettingsContextService,
useValue: {},
},
{
provide: WORKSPACE_SHELL_ACTIONS,
useValue: {
openAddPlaylistDialog: jest.fn(),
openGlobalSearch: jest.fn(),
openGlobalRecent: jest.fn(),
openAccountInfo: jest.fn(),
},
},
],
});
});
it('shows manage categories action for xtream category sections', () => {
const component = TestBed.runInInjectionContext(
() => new WorkspaceShellComponent()
);
component.currentUrl.set('/workspace/xtreams/pl-1/vod');
expect(component.canManageCategories()).toBe(true);
expect(component.contextActionGroups().hasSectionActions).toBe(true);
});
it('shows cleanup action for recent sections', () => {
const component = TestBed.runInInjectionContext(
() => new WorkspaceShellComponent()
);
component.currentUrl.set('/workspace/stalker/pl-1/recent');
expect(component.headerBulkAction()).toEqual(
expect.objectContaining({
icon: 'delete_sweep',
tooltip: 'Clear recently viewed',
})
);
expect(component.hasContextActions()).toBe(true);
});
});
File diff suppressed because it is too large. Load diff
@@ -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,101 @@
@use '../../nav-list.scss';
: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;
}
.option-row {
border-radius: 12px;
transition:
box-shadow 0.18s ease,
transform 0.18s ease;
.option-label,
.option-meta,
mat-icon {
transition: color 0.18s ease, opacity 0.18s ease;
}
&.selected {
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.option-label,
.option-meta,
mat-icon {
color: var(--app-selection-color) !important;
}
.option-meta {
opacity: 0.95;
}
}
}
.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,85 @@
import { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { PlaylistType, RecentPlaylistsComponent } from 'components';
import { selectActiveTypeFilters, selectAllPlaylistsMeta } from 'm3u-state';
import { map } from 'rxjs';
import { WORKSPACE_SHELL_ACTIONS } from '../workspace-shell/workspace-shell-actions';
@Component({
selector: 'app-workspace-sources',
imports: [RecentPlaylistsComponent],
templateUrl: './workspace-sources.component.html',
styleUrl: './workspace-sources.component.scss',
})
export class WorkspaceSourcesComponent {
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
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.workspaceActions.openAddPlaylistDialog(playlistType);
}
}
@@ -0,0 +1,91 @@
// Shared list navigation styles for workspace shell panels.
.nav-list {
flex: 1;
overflow-y: auto;
padding: 0 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
text-align: left;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
box-sizing: border-box;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
span,
.nav-item-label {
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
padding-left: 10px;
}
.nav-item-meta {
font-size: 0.8rem;
min-width: 2ch;
text-align: right;
flex-shrink: 0;
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
}
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
&.selected,
&.active {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
) !important;
color: var(--app-selection-color) !important;
border-color: var(--app-selection-border) !important;
font-weight: 600;
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
* {
color: var(--app-selection-color) !important;
}
.nav-item-meta {
color: var(--app-selection-color) !important;
opacity: 0.92;
}
}
}