mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
refactor: update nx monorepo structure
This commit is contained in:
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';
|
||||
+47
@@ -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>
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+104
@@ -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));
|
||||
}
|
||||
}
|
||||
+117
@@ -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>
|
||||
+186
@@ -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;
|
||||
}
|
||||
}
|
||||
+469
@@ -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];
|
||||
}
|
||||
}
|
||||
+73
@@ -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}`;
|
||||
}
|
||||
+52
@@ -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>
|
||||
+160
@@ -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;
|
||||
}
|
||||
+66
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+222
@@ -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]);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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>
|
||||
+176
@@ -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%);
|
||||
}
|
||||
}
|
||||
+201
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
}
|
||||
+173
@@ -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
+57
@@ -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>
|
||||
+101
@@ -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);
|
||||
}
|
||||
+162
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+12
@@ -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>
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user