Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# components
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test components` to execute the unit tests.
+37
View File
@@ -0,0 +1,37 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {
'@angular-eslint/template/click-events-have-key-events': 'off',
'@angular-eslint/template/interactive-supports-focus': 'off',
},
},
];
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'components',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/ui/components',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+28
View File
@@ -0,0 +1,28 @@
{
"name": "components",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/components/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:shared-ui", "type:ui"],
"targets": {
"test": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"command": [
"node",
"./tools/testing/run-web-esm-lib-tests.mjs",
"libs/ui/components/src"
],
"env": {
"NODE_OPTIONS": "--experimental-vm-modules"
},
"forwardAllArgs": true
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+11
View File
@@ -0,0 +1,11 @@
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
export * from './lib/channel-list-loading-state/channel-list-loading-state.component';
export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
export * from './lib/content-hero/content-hero.component';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/resizable/resizable.directive';
export * from './lib/season-container/season-container.component';
export * from './lib/watched-badge/watched-badge.component';
@@ -0,0 +1,60 @@
<div id="all-channels" class="channel-list">
@if (enrichedChannels().length === 0 && searchTerm()) {
<div class="no-results">
<mat-icon class="no-results-icon">search_off</mat-icon>
<div class="no-results-title">
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
</div>
<div class="no-results-hint">
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
</div>
</div>
} @else {
<cdk-virtual-scroll-viewport
[itemSize]="itemSize()"
class="scroll-viewport"
>
<app-channel-list-item
*cdkVirtualFor="
let channel of enrichedChannels();
index as i;
trackBy: trackByFn
"
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="channel.logo"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
(clicked)="onChannelClick(channel)"
(contextMenuRequested)="onChannelContextMenu(channel, $event)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="favoriteIds().has(channel?.url)"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
}
</div>
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
@@ -0,0 +1,105 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.scroll-viewport {
height: 100%;
overflow-x: hidden;
width: 100%;
::ng-deep .cdk-virtual-scroll-content-wrapper {
width: 100%;
max-width: 100%;
}
}
.search-panel {
padding: 10px 0 0 0;
display: flex;
justify-content: center;
}
.search-bar {
@include mat.form-field-density(-5);
& {
width: 96%;
}
}
#all-channels {
flex: 1;
min-height: 0;
overflow: hidden;
}
.channel-list {
overflow-x: hidden;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
:host ::ng-deep .search-bar div {
border-radius: 20px;
}
:host ::ng-deep .search-bar div[matformfieldlineripple],
:host ::ng-deep .search-bar .mat-mdc-form-field-subscript-wrapper {
display: none;
}
[type='search']::-webkit-search-cancel-button {
-webkit-appearance: none;
cursor: pointer;
appearance: none;
height: 10px;
width: 10px;
background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCA5NiA5NiIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAwIDAgOTYgOTYiIHhtbDpzcGFjZT0icHJlc2VydmUiPg0KPHBvbHlnb24gZmlsbD0iI2VlZSIgcG9pbnRzPSI5NiwxNCA4MiwwIDQ4LDM0IDE0LDAgMCwxNCAzNCw0OCAwLDgyIDE0LDk2IDQ4LDYyIDgyLDk2IDk2LDgyIDYyLDQ4ICIvPg0KPC9zdmc+DQo=');
background-size: 10px 10px;
color: #fff;
}
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
height: calc(100vh - 220px);
}
.no-results-icon {
font-size: 48px;
width: 48px;
height: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
.no-results-title {
font-size: 1.05rem;
font-weight: 520;
letter-spacing: -0.01em;
margin-bottom: 8px;
}
.no-results-hint {
opacity: 0.65;
font-size: 0.85rem;
}
@media only screen and (max-width: 599px) {
/* Mobile responsive tweaks if needed */
}
@@ -0,0 +1,157 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { AllChannelsViewComponent } from './all-channels-view.component';
function createChannel(id: string, name: string, url: string): Channel {
return {
epgParams: '',
group: {
title: 'News',
},
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id,
name,
radio: 'false',
tvg: {
id: `${id}-tvg`,
logo: '',
name,
rec: '7',
url: '',
},
url,
catchup: {
days: '7',
type: 'shift',
},
} as Channel;
}
describe('AllChannelsViewComponent', () => {
let fixture: ComponentFixture<AllChannelsViewComponent>;
let component: AllChannelsViewComponent;
let dialog: { open: jest.Mock };
const primaryChannel = createChannel(
'channel-1',
'News One',
'https://example.com/news-one.m3u8'
);
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
AllChannelsViewComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: dialog,
},
],
}).compileComponents();
fixture = TestBed.createComponent(AllChannelsViewComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('channels', [primaryChannel]);
fixture.componentRef.setInput('searchTerm', '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput('channelIconMap', new Map<string, string>());
fixture.componentRef.setInput('progressTick', 0);
fixture.componentRef.setInput('shouldShowEpg', false);
fixture.componentRef.setInput('itemSize', 48);
fixture.componentRef.setInput('favoriteIds', new Set<string>());
fixture.detectChanges();
});
it('stores viewport coordinates for the context menu and opens the dialog for that channel', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.mockImplementation();
component.onChannelContextMenu(
primaryChannel,
{
clientX: 144,
clientY: 188,
} as MouseEvent
);
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(primaryChannel);
expect(component.contextMenuPosition()).toEqual({
x: '144px',
y: '188px',
});
expect(openMenuSpy).toHaveBeenCalled();
component.openChannelDetails();
expect(dialog.open).toHaveBeenCalledWith(
ChannelDetailsDialogComponent,
expect.objectContaining({
data: primaryChannel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
})
);
});
it('keeps the playlist logo when an EPG icon is also available', () => {
const playlistLogo = 'https://example.com/playlist-logo.png';
const channel = {
...primaryChannel,
tvg: {
...primaryChannel.tvg,
logo: playlistLogo,
},
};
fixture.componentRef.setInput('channels', [channel]);
fixture.componentRef.setInput(
'channelIconMap',
new Map([[channel.tvg.id, 'https://example.com/epg-logo.png']])
);
fixture.detectChanges();
expect(component.enrichedChannels()[0].logo).toBe(playlistLogo);
});
it('falls back to the EPG icon using tvg-name when tvg-id and playlist logo are missing', () => {
const channel = {
...primaryChannel,
tvg: {
...primaryChannel.tvg,
id: '',
logo: '',
name: 'Guide News',
},
};
fixture.componentRef.setInput('channels', [channel]);
fixture.componentRef.setInput(
'channelIconMap',
new Map([['Guide News', 'https://example.com/guide-news.png']])
);
fixture.detectChanges();
expect(component.enrichedChannels()[0].logo).toBe(
'https://example.com/guide-news.png'
);
});
});
@@ -0,0 +1,195 @@
import { ScrollingModule } from '@angular/cdk/scrolling';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
/** Enriched channel with pre-computed EPG and progress data */
export interface EnrichedChannel extends Channel {
epgProgram: EpgProgram | null | undefined;
logo: string;
progressPercentage: number;
}
@Component({
selector: 'app-all-channels-view',
templateUrl: './all-channels-view.component.html',
styleUrls: ['./all-channels-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
MatIconModule,
MatMenuModule,
ScrollingModule,
TranslatePipe,
],
})
export class AllChannelsViewComponent {
private readonly dialog = inject(MatDialog);
readonly contextMenuTrigger = viewChild.required<MatMenuTrigger>(
'contextMenuTrigger'
);
/** All channels (will be filtered by search) */
readonly channels = input.required<Channel[]>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
/** Item size for virtual scroll */
readonly itemSize = input.required<number>();
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
/** Set of favorite channel URLs */
readonly favoriteIds = input<Set<string>>(new Set());
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** Emits when favorite is toggled */
readonly favoriteToggled = output<{
channel: Channel;
event: MouseEvent;
}>();
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
/**
* Computed signal for filtered and enriched channels.
*/
readonly enrichedChannels = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
const channels = this.channels();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
// Read progressTick to create dependency for progress refresh
this.progressTick();
let result = channels;
// Filter if search term exists
if (term) {
result = channels.filter((ch) =>
ch.name?.toLowerCase().includes(term)
);
}
// Enrich with EPG data and pre-calculate progress
return result.map((channel) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
});
/**
* Calculates progress percentage for an EPG program
*/
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = Date.now();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
// Validate start/stop are finite numbers
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
return 0;
}
const total = stop - start;
// Bail out if duration is zero or negative
if (total <= 0) {
return 0;
}
// Clamp elapsed to [0, total]
const elapsed = Math.min(total, Math.max(0, now - start));
return Math.round((elapsed / total) * 100);
}
trackByFn(_: number, channel: Channel): string {
return channel?.id;
}
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggle(channel: Channel, event: MouseEvent): void {
this.favoriteToggled.emit({ channel, event });
}
onChannelContextMenu(channel: Channel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
}
}
@@ -0,0 +1,233 @@
<section mat-dialog-title class="channel-dialog__hero">
<div class="channel-dialog__hero-copy">
<div class="channel-dialog__eyebrow">
<span class="channel-dialog__eyebrow-mark"></span>
{{ channel.group?.title || 'M3U' }}
</div>
<div class="channel-dialog__headline-row">
<div class="channel-dialog__headline-text">
@if (channel.tvg?.logo) {
<img
class="channel-dialog__logo"
[src]="channel.tvg.logo"
[alt]="channel.name"
(error)="logoError = true"
[hidden]="logoError"
/>
}
<div>
<h2 class="channel-dialog__title">{{ channel.name }}</h2>
<p class="channel-dialog__subtitle">
{{ 'CHANNELS.DETAILS_DIALOG.DESCRIPTION' | translate }}
</p>
</div>
</div>
<div class="channel-dialog__status-cluster">
@if (channel.radio === 'true') {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--radio"
>
{{ 'CHANNELS.DETAILS_DIALOG.RADIO' | translate }}
</span>
}
@if (catchupAvailable) {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--active"
>
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP_AVAILABLE' | translate }}
</span>
}
@if (catchupPlaybackSupported) {
<span
class="channel-dialog__status-pill channel-dialog__status-pill--playback"
>
{{ 'CHANNELS.DETAILS_DIALOG.PLAYBACK_SUPPORTED' | translate }}
</span>
}
</div>
</div>
</div>
<div class="channel-dialog__hero-stats">
@for (stat of heroStats; track stat.labelKey) {
<article class="channel-stat">
<div class="channel-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="channel-stat__label">
{{ stat.labelKey | translate }}
</div>
<div
class="channel-stat__value"
[class.channel-stat__value--mono]="stat.mono"
[class.channel-stat__value--empty]="stat.empty"
>
@if (stat.valueKey) {
{{ stat.valueKey | translate: stat.translateParams }}
} @else {
{{ stat.value }}
}
</div>
</article>
}
</div>
</section>
<mat-dialog-content class="channel-dialog__content">
<div class="channel-dialog__grid">
<section class="channel-panel">
<div class="channel-panel__header">
<p class="channel-panel__eyebrow">
{{ 'CHANNELS.DETAILS_DIALOG.SUMMARY' | translate }}
</p>
<h3>{{ 'CHANNELS.DETAILS_DIALOG.STREAM_URL' | translate }}</h3>
</div>
<div class="channel-dialog__url-block">
<code>{{ channel.url }}</code>
<button
mat-icon-button
class="channel-dialog__copy-btn"
[cdkCopyToClipboard]="channel.url"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
<dl class="channel-detail-list">
@for (field of streamFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
@if (hasTvgData) {
<div class="channel-chip-section">
<p class="channel-chip-section__label">
{{ 'CHANNELS.DETAILS_DIALOG.TVG' | translate }}
</p>
<dl class="channel-detail-list">
@for (field of tvgFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate
: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
</div>
}
</section>
<section class="channel-panel">
<div class="channel-panel__header">
<p class="channel-panel__eyebrow">
{{ 'CHANNELS.DETAILS_DIALOG.CATCHUP' | translate }}
</p>
<h3>
{{ channel.catchup?.type || ('CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE' | translate) }}
</h3>
</div>
<dl class="channel-detail-list">
@for (field of catchupFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
@if (hasHttpData) {
<div class="channel-chip-section">
<p class="channel-chip-section__label">
{{ 'CHANNELS.DETAILS_DIALOG.HTTP' | translate }}
</p>
<dl class="channel-detail-list">
@for (field of httpFields; track field.labelKey) {
<div class="channel-detail-row">
<dt>{{ field.labelKey | translate }}</dt>
<dd
[class.channel-detail-row__value--mono]="
field.monospace
"
[class.channel-detail-row__value--empty]="
field.empty
"
>
@if (field.valueKey) {
{{
field.valueKey
| translate
: field.translateParams
}}
} @else {
{{ field.value }}
}
</dd>
</div>
}
</dl>
</div>
}
</section>
</div>
</mat-dialog-content>
<mat-dialog-actions class="channel-dialog__actions">
<button mat-stroked-button mat-dialog-close class="channel-dialog__close">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,390 @@
:host {
display: block;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
// ─── Hero header ────────────────────────────────────────────────────────────
.channel-dialog__hero {
margin: 0;
padding: 20px 20px 18px;
display: grid;
gap: 18px;
background:
var(--app-accent-gradient),
var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
.channel-dialog__hero-copy {
display: grid;
gap: 10px;
}
.channel-dialog__eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.channel-dialog__eyebrow-mark {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--mat-sys-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--mat-sys-primary) 14%, transparent);
}
.channel-dialog__headline-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.channel-dialog__headline-text {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.channel-dialog__logo {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: contain;
background: var(--mat-sys-surface-container);
border: 1px solid var(--app-separator);
flex-shrink: 0;
}
.channel-dialog__title {
margin: 0;
font-size: 1.3rem;
line-height: 1.2;
font-weight: 650;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.channel-dialog__subtitle {
margin: 4px 0 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.88rem;
}
// ─── Status pills ───────────────────────────────────────────────────────────
.channel-dialog__status-cluster {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
flex-shrink: 0;
}
.channel-dialog__status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container);
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.channel-dialog__status-pill--active {
border-color: color-mix(in srgb, #34d399 30%, transparent);
background: color-mix(in srgb, #34d399 12%, transparent);
color: #2db36f;
}
.channel-dialog__status-pill--radio {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 30%, transparent);
background: color-mix(in srgb, var(--mat-sys-tertiary) 12%, transparent);
color: var(--mat-sys-tertiary);
}
.channel-dialog__status-pill--playback {
border-color: color-mix(in srgb, var(--mat-sys-primary) 30%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color: var(--mat-sys-primary);
}
// ─── Hero stat cards ────────────────────────────────────────────────────────
.channel-dialog__hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.channel-stat {
display: grid;
gap: 4px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--app-widget-border, transparent);
background: var(--app-widget-bg, var(--mat-sys-surface-container));
box-shadow: var(--app-widget-shadow, none);
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
}
.channel-stat__icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
color: var(--mat-sys-primary);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.channel-stat__label {
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.channel-stat__value {
font-size: 1.1rem;
font-weight: 650;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
font-size: 0.92rem;
}
&--empty {
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-style: italic;
font-weight: 500;
font-size: 0.88rem;
}
}
// ─── Content area ───────────────────────────────────────────────────────────
.channel-dialog__content {
margin: 0;
padding: 20px;
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.channel-dialog__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
// ─── URL block ──────────────────────────────────────────────────────────────
.channel-dialog__url-block {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 10px 10px 14px;
border-radius: 10px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container);
code {
flex: 1;
min-width: 0;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
font-size: 0.82rem;
line-height: 1.5;
color: var(--app-body-color, var(--mat-sys-on-surface));
overflow-wrap: anywhere;
word-break: break-word;
}
}
.channel-dialog__copy-btn {
flex-shrink: 0;
width: 32px;
height: 32px;
--mdc-icon-button-state-layer-size: 32px;
--mdc-icon-button-icon-size: 18px;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
}
// ─── Detail panels ──────────────────────────────────────────────────────────
.channel-panel {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--app-widget-border, transparent);
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
box-shadow: var(--app-widget-shadow, none);
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
align-content: start;
}
.channel-panel__header {
display: grid;
gap: 4px;
}
.channel-panel__header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.channel-panel__eyebrow {
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
// ─── Detail rows ────────────────────────────────────────────────────────────
.channel-detail-list {
display: grid;
gap: 0;
margin: 0;
}
.channel-detail-row {
display: grid;
grid-template-columns: minmax(100px, 130px) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px solid var(--app-separator);
}
.channel-detail-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.channel-detail-row dt {
margin: 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.channel-detail-row dd {
margin: 0;
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.88rem;
font-weight: 500;
word-break: break-word;
overflow-wrap: anywhere;
}
.channel-detail-row__value--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
font-size: 0.82rem;
}
.channel-detail-row__value--empty {
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-style: italic;
}
// ─── Chip sections (sub-group headers) ──────────────────────────────────────
.channel-chip-section {
display: grid;
gap: 10px;
padding-top: 4px;
}
.channel-chip-section__label {
margin: 0;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
padding-top: 4px;
border-top: 1px solid var(--app-separator);
}
// ─── Footer actions ─────────────────────────────────────────────────────────
.channel-dialog__actions {
padding: 0 20px 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.channel-dialog__close {
min-width: 100px;
border-radius: 999px;
}
// ─── Responsive breakpoints ─────────────────────────────────────────────────
@media (max-width: 720px) {
.channel-dialog__hero,
.channel-dialog__content,
.channel-dialog__actions {
padding-left: 16px;
padding-right: 16px;
}
.channel-dialog__headline-row {
flex-direction: column;
align-items: flex-start;
}
.channel-dialog__status-cluster {
justify-content: flex-start;
}
.channel-dialog__hero-stats,
.channel-dialog__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.channel-detail-row {
grid-template-columns: 1fr;
gap: 4px;
}
}
@media (max-width: 480px) {
.channel-dialog__hero-stats {
grid-template-columns: 1fr;
}
.channel-dialog__grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,75 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from './channel-details-dialog.component';
describe('ChannelDetailsDialogComponent', () => {
let fixture: ComponentFixture<ChannelDetailsDialogComponent>;
let component: ChannelDetailsDialogComponent;
const channel: Channel = {
epgParams: 'src=test-playlist',
group: {
title: 'News',
},
http: {
origin: 'https://provider.example.com',
referrer: 'https://provider.example.com/list',
'user-agent': 'IPTVnator Test',
},
id: 'channel-1',
name: 'News One',
radio: 'false',
tvg: {
id: 'news-one',
logo: 'https://provider.example.com/logo.png',
name: 'News One HD',
rec: '5',
url: 'https://provider.example.com/guide.xml',
},
url: 'https://provider.example.com/stream.m3u8',
catchup: {
days: '5',
source: 'https://provider.example.com/catchup.m3u8',
type: 'append',
},
timeshift: '5',
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ChannelDetailsDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: channel,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ChannelDetailsDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('derives archive window and playback support from parsed catchup metadata', () => {
expect(component.archiveDays).toBe(5);
expect(component.catchupAvailable).toBe(true);
expect(component.catchupPlaybackSupported).toBe(true);
expect(component.heroStats).toEqual(
expect.arrayContaining([
expect.objectContaining({
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
translateParams: { count: 5 },
valueKey: 'CHANNELS.DETAILS_DIALOG.DAYS_OTHER',
}),
])
);
});
});
@@ -0,0 +1,185 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { getM3uArchiveDays, isM3uCatchupPlaybackSupported } from 'm3u-utils';
import { Channel } from 'shared-interfaces';
interface ChannelDetailField {
readonly empty?: boolean;
readonly labelKey: string;
readonly monospace?: boolean;
readonly translateParams?: Record<string, number>;
readonly value?: string;
readonly valueKey?: string;
}
interface HeroStat {
readonly empty?: boolean;
readonly icon: string;
readonly labelKey: string;
readonly mono?: boolean;
readonly translateParams?: Record<string, number>;
readonly value?: string;
readonly valueKey?: string;
}
@Component({
selector: 'app-channel-details-dialog',
templateUrl: './channel-details-dialog.component.html',
styleUrls: ['./channel-details-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ClipboardModule, MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
})
export class ChannelDetailsDialogComponent {
readonly channel = inject<Channel>(MAT_DIALOG_DATA);
readonly archiveDays = getM3uArchiveDays(this.channel);
readonly catchupAvailable = this.archiveDays > 0;
readonly catchupPlaybackSupported = isM3uCatchupPlaybackSupported(
this.channel
);
logoError = false;
readonly hasTvgData = !!(
this.channel.tvg?.id ||
this.channel.tvg?.name ||
this.channel.tvg?.url ||
this.channel.tvg?.rec
);
readonly hasHttpData = !!(
this.channel.http?.origin ||
this.channel.http?.referrer ||
this.channel.http?.['user-agent']
);
readonly heroStats: HeroStat[] = [
{
icon: 'tag',
labelKey: 'CHANNELS.DETAILS_DIALOG.CHANNEL_ID',
value: this.channel.id?.trim() || undefined,
valueKey: this.channel.id?.trim()
? undefined
: 'CHANNELS.DETAILS_DIALOG.EMPTY',
mono: true,
empty: !this.channel.id?.trim(),
},
{
icon: 'folder',
labelKey: 'CHANNELS.DETAILS_DIALOG.GROUP',
value: this.channel.group?.title?.trim() || undefined,
valueKey: this.channel.group?.title?.trim()
? undefined
: 'CHANNELS.DETAILS_DIALOG.EMPTY',
empty: !this.channel.group?.title?.trim(),
},
this.createArchiveHeroStat(),
{
icon: 'schedule',
labelKey: 'CHANNELS.DETAILS_DIALOG.TIMESHIFT',
value: this.channel.timeshift?.trim() || undefined,
valueKey: this.channel.timeshift?.trim()
? undefined
: 'CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE',
empty: !this.channel.timeshift?.trim(),
},
];
readonly streamFields: ChannelDetailField[] = [
this.field(
'CHANNELS.DETAILS_DIALOG.EPG_PARAMS',
this.channel.epgParams,
true
),
this.field(
'CHANNELS.DETAILS_DIALOG.TVG_LOGO',
this.channel.tvg?.logo,
true
),
];
readonly tvgFields: ChannelDetailField[] = [
this.field('CHANNELS.DETAILS_DIALOG.TVG_ID', this.channel.tvg?.id, true),
this.field('CHANNELS.DETAILS_DIALOG.TVG_NAME', this.channel.tvg?.name),
this.field(
'CHANNELS.DETAILS_DIALOG.TVG_URL',
this.channel.tvg?.url,
true
),
this.field('CHANNELS.DETAILS_DIALOG.TVG_REC', this.channel.tvg?.rec),
];
readonly catchupFields: ChannelDetailField[] = [
this.field(
'CHANNELS.DETAILS_DIALOG.CATCHUP_SOURCE',
this.channel.catchup?.source,
true
),
this.field(
'CHANNELS.DETAILS_DIALOG.CATCHUP_DAYS',
this.channel.catchup?.days
),
];
readonly httpFields: ChannelDetailField[] = [
this.field(
'CHANNELS.DETAILS_DIALOG.HTTP_ORIGIN',
this.channel.http?.origin,
true
),
this.field(
'CHANNELS.DETAILS_DIALOG.HTTP_REFERRER',
this.channel.http?.referrer,
true
),
this.field(
'CHANNELS.DETAILS_DIALOG.HTTP_USER_AGENT',
this.channel.http?.['user-agent'],
true
),
];
private createArchiveHeroStat(): HeroStat {
if (!this.catchupAvailable) {
return {
empty: true,
icon: 'history',
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
valueKey: 'CHANNELS.DETAILS_DIALOG.NOT_AVAILABLE',
};
}
return {
icon: 'history',
labelKey: 'CHANNELS.DETAILS_DIALOG.WINDOW',
translateParams: { count: this.archiveDays },
valueKey:
this.archiveDays === 1
? 'CHANNELS.DETAILS_DIALOG.DAYS_ONE'
: 'CHANNELS.DETAILS_DIALOG.DAYS_OTHER',
};
}
private field(
labelKey: string,
value: string | null | undefined,
monospace = false
): ChannelDetailField {
const normalized = value?.trim() ?? '';
if (!normalized) {
return {
empty: true,
labelKey,
monospace,
valueKey: 'CHANNELS.DETAILS_DIALOG.EMPTY',
};
}
return { labelKey, monospace, value: normalized };
}
}
@@ -0,0 +1,98 @@
@if (channelsLoading()) {
<app-channel-list-loading-state [view]="activeView()" />
} @else if (displayedChannels().length) {
@if (viewTitle() && activeView() !== 'groups') {
<header class="context-header">
<div class="context-header__top">
<h2>{{ viewTitle() | translate }}</h2>
@if (
activeView() === 'recent' &&
recentChannelItems().length > 0
) {
<button mat-button (click)="clearRecentChannels()">
{{ 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION' | translate }}
</button>
}
</div>
</header>
<div class="context-divider"></div>
}
@switch (activeView()) {
@case ('all') {
<app-all-channels-view
[channels]="displayedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[itemSize]="itemSize()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
}
@case ('groups') {
<app-groups-view
[groupedChannels]="groupedChannels()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
[favoriteIds]="favoriteIds()"
[hiddenGroupTitles]="hiddenGroupTitles()"
[sidebarWidth]="sidebarWidth()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(hiddenGroupTitlesChanged)="
onHiddenGroupTitlesChanged($event)
"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
/>
}
@case ('favorites') {
@let favorites = favorites$ | async;
@if (favorites) {
<app-favorites-view
[favorites]="favorites"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
(channelSelected)="onChannelSelected($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(favoritesReordered)="onFavoritesReordered($event)"
/>
}
}
@case ('recent') {
<app-recent-view
[recentItems]="recentChannelItems()"
[searchTerm]="workspaceSearchTerm()"
[channelEpgMap]="channelEpgMap()"
[channelIconMap]="channelIconMap()"
[progressTick]="progressTick()"
[shouldShowEpg]="shouldShowEpg()"
[activeChannelUrl]="activeChannelUrl()"
(channelSelected)="onChannelSelected($event)"
(removeRecent)="removeRecentChannel($event)"
/>
}
}
} @else {
<div class="empty-source-state">
<div class="empty-source-state__icon-wrapper">
<mat-icon class="empty-source-state__icon">folder_off</mat-icon>
</div>
<h3 class="empty-source-state__title">
{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}
</h3>
</div>
}
@@ -0,0 +1,99 @@
:host {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
container-type: inline-size;
}
:host > app-channel-list-loading-state {
flex: 1;
min-height: 0;
}
::ng-deep {
.mat-mdc-tab.mdc-tab {
padding: 0 5px;
}
}
.empty-source-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0;
padding: 32px 24px;
text-align: center;
}
.empty-source-state__icon-wrapper {
width: 64px;
height: 64px;
border-radius: 20px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow:
0 8px 24px -8px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
}
.empty-source-state__icon {
width: 32px;
height: 32px;
font-size: 32px;
opacity: 0.8;
}
.empty-source-state__title {
font-size: 1.05rem;
font-weight: 520;
margin: 0;
letter-spacing: -0.01em;
}
@media only screen and (max-width: 599px) {
.tab-title {
display: none;
}
.tab-icon {
display: block;
}
}
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 12px 16px 8px;
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);
}
}
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
}
@@ -0,0 +1,141 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { convertToParamMap, ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { of, Subject } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { ChannelActions, PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { ChannelListContainerComponent } from './channel-list-container.component';
describe('ChannelListContainerComponent', () => {
let fixture: ComponentFixture<ChannelListContainerComponent>;
let dispatch: jest.Mock;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
beforeEach(async () => {
const routerEvents$ = new Subject<NavigationEnd>();
dispatch = jest.fn();
activePlaylistSignal = signal<PlaylistMeta | null>({
_id: 'playlist-1',
title: 'Playlist One',
count: 0,
importDate: '2026-04-11T00:00:00.000Z',
hiddenGroupTitles: ['News'],
} as PlaylistMeta);
const route = {
snapshot: {
data: { layout: 'workspace' },
paramMap: convertToParamMap({}),
queryParamMap: convertToParamMap({}),
params: {},
queryParams: {},
},
pathFromRoot: [
{
snapshot: {
data: { layout: 'workspace' },
paramMap: convertToParamMap({}),
params: {},
},
paramMap: of(convertToParamMap({})),
},
],
paramMap: of(convertToParamMap({})),
queryParamMap: of(convertToParamMap({})),
} as unknown as ActivatedRoute;
await TestBed.configureTestingModule({
imports: [ChannelListContainerComponent],
providers: [
{
provide: EpgService,
useValue: {
getChannelMetadataForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
getCurrentProgramsForChannels: jest
.fn()
.mockReturnValue(of(new Map())),
},
},
{
provide: PlaylistsService,
useValue: {},
},
{
provide: StorageMap,
useValue: {
get: jest.fn().mockReturnValue(of({})),
},
},
{
provide: Store,
useValue: {
dispatch,
select: jest.fn().mockReturnValue(of([])),
selectSignal: jest.fn(() => signal(undefined)),
},
},
{
provide: Router,
useValue: {
url: '/workspace/playlists/demo/all',
events: routerEvents$.asObservable(),
},
},
{
provide: ActivatedRoute,
useValue: route,
},
{
provide: PlaylistContextFacade,
useValue: {
activePlaylist: activePlaylistSignal,
resolvedPlaylistId: signal(null),
},
},
],
})
.overrideComponent(ChannelListContainerComponent, {
set: {
template: '',
imports: [],
},
})
.compileComponents();
fixture = TestBed.createComponent(ChannelListContainerComponent);
});
it('does not clear the shared channel list on destroy', () => {
fixture.detectChanges();
fixture.destroy();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
});
it('dispatches playlist meta updates when hidden group titles change', () => {
fixture.componentInstance.onHiddenGroupTitlesChanged([
'Movies',
'Sports',
]);
expect(dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: 'playlist-1',
hiddenGroupTitles: ['Movies', 'Sports'],
} as PlaylistMeta,
})
);
});
});
@@ -0,0 +1,439 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
Input,
input,
OnDestroy,
OnInit,
output,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import {
ChannelActions,
FavoritesActions,
PlaylistActions,
resolveChannelEpgLookupKey,
selectActive,
selectFavorites,
} from 'm3u-state';
import {
BehaviorSubject,
combineLatest,
filter,
forkJoin,
firstValueFrom,
map,
} from 'rxjs';
import { PlaylistsService } from 'services';
import {
Channel,
EpgProgram,
isM3uRecentlyViewedItem,
normalizeStalkerDate,
PlaylistMeta,
PlaylistRecentlyViewedItem,
Settings,
STORE_KEY,
} from 'shared-interfaces';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
import {
RecentViewComponent,
RecentViewItem,
} from './recent-view/recent-view.component';
import { ChannelListLoadingStateComponent } from '../channel-list-loading-state/channel-list-loading-state.component';
function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
return channels.reduce<Record<string, Channel[]>>((groups, channel) => {
const key = channel.group?.title ?? '';
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(channel);
return groups;
}, {});
}
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
styleUrls: ['./channel-list-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AllChannelsViewComponent,
ChannelListLoadingStateComponent,
CommonModule,
FavoritesViewComponent,
GroupsViewComponent,
MatButtonModule,
MatIconModule,
RecentViewComponent,
TranslatePipe,
],
})
export class ChannelListContainerComponent implements OnInit, OnDestroy {
private readonly epgService = inject(EpgService);
private readonly playlistsService = inject(PlaylistsService);
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly playlistContext = inject(PlaylistContextFacade);
/** Map of channel ID to current EPG program */
readonly channelEpgMap = signal(new Map<string, EpgProgram | null>());
readonly channelIconMap = signal(new Map<string, string>());
/** Interval for refreshing EPG data */
private epgRefreshInterval?: number;
/** Global progress tick signal - triggers re-computation of progress percentages */
readonly progressTick = signal(0);
/** Interval for global progress updates */
private progressInterval?: number;
/** Whether to show EPG data in channel items */
readonly shouldShowEpg = signal(false);
/** Item size for virtual scroll - compact when no EPG */
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
/** Active view (all, groups, favorites, recent) */
readonly activeView = input<string>('all');
readonly channelsLoading = input(false);
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
'q',
(value) => (value ?? '').trim().toLowerCase()
);
readonly workspaceSearchTerm = computed(() =>
this.isWorkspaceLayout ? this.routeSearchTerm() : ''
);
readonly currentUrl = toSignal(
this.router.events.pipe(
filter((event) => event instanceof NavigationEnd),
map((event) => (event as NavigationEnd).urlAfterRedirects)
),
{ initialValue: this.router.url }
);
readonly viewTitle = computed(() => {
const view = this.activeView();
const url = this.currentUrl();
if (view === 'all') return 'CHANNELS.ALL_CHANNELS';
if (view === 'groups') return 'CHANNELS.GROUPS';
if (view === 'recent') return 'PORTALS.SIDEBAR.RECENT';
if (view === 'favorites') {
return url.includes('/workspace/global-favorites')
? 'HOME.PLAYLISTS.GLOBAL_FAVORITES'
: 'CHANNELS.FAVORITES';
}
return '';
});
/** Channels array */
_channelList: Channel[] = [];
private readonly channelListSignal = signal<Channel[]>([]);
private channelList$ = new BehaviorSubject<Channel[]>([]);
get channelList(): Channel[] {
return this._channelList;
}
@Input()
set channelList(value: Channel[]) {
const safeValue = value ?? [];
this._channelList = safeValue;
this.channelListSignal.set(safeValue);
this.channelList$.next(safeValue);
this.fetchEpgForChannels(safeValue);
}
/** Route-aware playlist ID for recent-item mutations */
private readonly resolvedPlaylistId = this.playlistContext.resolvedPlaylistId;
private readonly activePlaylist = this.playlistContext.activePlaylist;
readonly hiddenGroupTitles = computed(() => {
const playlist = this.activePlaylist();
if (!playlist || playlist.serverUrl || playlist.macAddress) {
return [];
}
return playlist.hiddenGroupTitles ?? [];
});
/** Displayed channels - filters out unfavorited channels in global favorites view */
readonly displayedChannels = computed(() => {
return this.channelListSignal();
});
/** Object with channels sorted by groups */
readonly groupedChannels = computed(() =>
groupChannelsByTitle(this.displayedChannels())
);
readonly recentChannelItems = computed<RecentViewItem[]>(() => {
const channels = this.channelListSignal();
const recentItems = this.recentItems();
const channelsByUrl = new Map(
channels.map((channel) => [channel.url, channel] as const)
);
const channelsById = new Map(
channels.map((channel) => [channel.id, channel] as const)
);
const seenUrls = new Set<string>();
return [...recentItems]
.filter(isM3uRecentlyViewedItem)
.sort(
(a, b) =>
new Date(normalizeStalkerDate(b.added_at)).getTime() -
new Date(normalizeStalkerDate(a.added_at)).getTime()
)
.reduce<RecentViewItem[]>((acc, item) => {
const channelUrl = item.url?.trim();
if (!channelUrl || seenUrls.has(channelUrl)) {
return acc;
}
const channel =
channelsByUrl.get(channelUrl) ||
(item.channel_id
? channelsById.get(item.channel_id)
: undefined);
if (!channel) {
return acc;
}
seenUrls.add(channel.url);
acc.push({
channel,
viewedAt: normalizeStalkerDate(item.added_at),
});
return acc;
}, []);
});
/** Selected channel */
readonly activeChannel = this.store.selectSignal(selectActive);
/** Active channel URL for highlighting */
readonly activeChannelUrl = computed(() => this.activeChannel()?.url);
/** Set of favorite channel URLs for quick lookup */
private readonly _favorites = this.store.selectSignal(selectFavorites);
readonly favoriteIds = computed(() => new Set(this._favorites()));
/** List with favorites */
favorites$ = combineLatest([
this.store.select(selectFavorites),
this.channelList$,
]).pipe(
map(([favoriteChannelIds, channelList]) => {
return favoriteChannelIds
.map((favoriteChannelId) =>
channelList.find(
(channel) => channel.url === favoriteChannelId
)
)
.filter((channel): channel is Channel => channel !== undefined);
})
);
ngOnInit(): void {
// Check if EPG should be shown (only in Electron with configured EPG URL)
const isElectron = !!window['electron'];
if (isElectron) {
this.storage
.get(STORE_KEY.Settings)
.subscribe((settings: unknown) => {
if (
settings &&
Object.keys(settings as Settings).length > 0
) {
const epgUrl = (settings as Settings).epgUrl;
this.shouldShowEpg.set(!!(epgUrl && epgUrl.length > 0));
}
});
} else {
this.shouldShowEpg.set(false);
}
// Set up EPG refresh interval (every 60 seconds)
this.epgRefreshInterval = window.setInterval(() => {
this.fetchEpgForChannels(this._channelList);
}, 60000);
// Set up global progress update interval (every 30 seconds)
this.progressInterval = window.setInterval(() => {
this.progressTick.update((v) => v + 1);
}, 30000);
}
ngOnDestroy(): void {
this.store.dispatch(ChannelActions.resetActiveChannel());
if (this.epgRefreshInterval) {
clearInterval(this.epgRefreshInterval);
}
if (this.progressInterval) {
clearInterval(this.progressInterval);
}
this.channelList$.complete();
}
/**
* Fetches EPG data for all channels
*/
private fetchEpgForChannels(channels: Channel[]): void {
if (!channels || channels.length === 0) {
this.channelEpgMap.set(new Map());
this.channelIconMap.set(new Map());
return;
}
const channelIds = Array.from(
new Set(
channels
.map((channel) => resolveChannelEpgLookupKey(channel))
.filter((id) => !!id)
)
);
forkJoin({
epgMap: this.epgService.getCurrentProgramsForChannels(channelIds),
metadataMap:
this.epgService.getChannelMetadataForChannels(channelIds),
}).subscribe(({ epgMap, metadataMap }) => {
this.channelEpgMap.set(epgMap);
this.channelIconMap.set(
new Map(
Array.from(metadataMap.entries(), ([channelId, metadata]) => [
channelId,
metadata?.iconUrl?.trim() || '',
])
)
);
});
}
/**
* Handles channel selection from any tab
*/
onChannelSelected(channel: Channel): void {
this.store.dispatch(ChannelActions.setActiveChannel({ channel }));
}
/**
* Handles favorite toggle from favorites tab
*/
onFavoriteToggled(event: { channel: Channel; event: MouseEvent }): void {
event.event.stopPropagation();
this.store.dispatch(
FavoritesActions.updateFavorites({ channel: event.channel })
);
}
onHiddenGroupTitlesChanged(hiddenGroupTitles: string[]): void {
const playlist = this.activePlaylist();
if (!playlist || playlist.serverUrl || playlist.macAddress) {
return;
}
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlist._id,
hiddenGroupTitles,
} as PlaylistMeta,
})
);
}
/**
* Handles favorites reorder from drag-drop
*/
onFavoritesReordered(channelIds: string[]): void {
this.store.dispatch(FavoritesActions.setFavorites({ channelIds }));
}
async removeRecentChannel(channelUrl: string): Promise<void> {
const playlistId = this.resolvedPlaylistId();
if (!playlistId) {
return;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
playlistId,
channelUrl
)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
async clearRecentChannels(): Promise<void> {
const playlistId = this.resolvedPlaylistId();
if (!playlistId) {
return;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearM3uRecentlyViewed(playlistId)
);
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
} as PlaylistMeta,
}) as any
);
}
onSidebarWidthRequested(width: number): void {
this.sidebarWidthRequested.emit(width);
}
onSidebarWidthRequestEnded(width: number): void {
this.sidebarWidthRequestEnded.emit(width);
}
}
@@ -0,0 +1,113 @@
<div
class="channel-list-item"
[class.compact]="!showEpg()"
cdkDrag
[cdkDragDisabled]="!isDraggable()"
cdkDragPreviewContainer="parent"
[class.active]="selected()"
(click)="clicked.emit()"
(contextmenu)="onContextMenu($event)"
data-test-id="channel-item"
>
@if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon">drag_indicator</mat-icon>
}
<div class="channel-content">
<div class="channel-logo-shell" aria-hidden="true">
@if (showLogoFallback()) {
<mat-icon class="channel-logo-fallback">live_tv</mat-icon>
}
@if (logo()) {
<img
class="channel-logo"
[src]="logo()"
(error)="onLogoError($event)"
alt="Channel Logo"
/>
}
</div>
<div class="channel-details">
<div class="channel-name">{{ name() }}</div>
@if (showEpg()) {
@if (epgProgram(); as program) {
<div class="epg-title">
{{ program.title }}
</div>
<div class="epg-timeline">
<span class="epg-time">{{
(program.startTimestamp
? program.startTimestamp * 1000
: program.start
) | date: 'HH:mm'
}}</span>
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[ngStyle]="{
width: progressPercentage() + '%',
}"
></div>
</div>
<span class="epg-time">{{
(program.stopTimestamp
? program.stopTimestamp * 1000
: program.stop
) | date: 'HH:mm'
}}</span>
</div>
} @else if (!isRadio()) {
<div class="epg-placeholder">
{{ 'EPG.NO_PROGRAM_INFO' | translate }}
</div>
}
}
</div>
</div>
@if (
(showProgramInfoButton() && showEpg() && epgProgram()) ||
showFavoriteButton() ||
showAuxActionButton()
) {
<div class="action-buttons">
@if (showProgramInfoButton() && showEpg() && epgProgram(); as program) {
<button
mat-icon-button
class="program-info-button"
[matTooltip]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
"
(click)="showProgramDescription(program, $event)"
>
<mat-icon>info</mat-icon>
</button>
}
@if (showFavoriteButton()) {
<button
mat-icon-button
class="favorite-button"
[matTooltip]="
(isFavorite()
? 'CHANNELS.REMOVE_FAVORITE'
: 'CHANNELS.ADD_FAVORITE'
) | translate
"
(click)="onFavoriteClick($event)"
>
<mat-icon>{{
isFavorite() ? 'star' : 'star_outline'
}}</mat-icon>
</button>
}
@if (showAuxActionButton()) {
<button
mat-icon-button
class="favorite-button"
[matTooltip]="auxActionTooltip()"
(click)="onAuxActionClick($event)"
>
<mat-icon>{{ auxActionIcon() }}</mat-icon>
</button>
}
</div>
}
</div>
@@ -0,0 +1,301 @@
:host {
display: block;
width: 100%;
container-type: inline-size;
}
.channel-list-item {
width: 100%;
min-height: 68px;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px 8px 12px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: inherit;
cursor: pointer;
box-sizing: border-box;
overflow: hidden;
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease,
color 0.15s ease;
&:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
&.active {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
color: var(--app-selection-color);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
}
&.compact {
min-height: 52px;
padding-block: 7px;
}
}
.drag-icon {
flex-shrink: 0;
cursor: move;
opacity: 0.46;
}
.channel-content {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.channel-logo-shell {
position: relative;
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 10px;
display: grid;
place-items: center;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.12);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 1px 1px rgba(0, 0, 0, 0.15);
.compact & {
width: 38px;
height: 38px;
}
}
.channel-logo-fallback {
position: absolute;
inset: 0;
margin: auto;
width: 18px;
height: 18px;
font-size: 18px;
opacity: 0.5;
}
.channel-logo {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
box-sizing: border-box;
}
.channel-details {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
}
.channel-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.96rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
}
.epg-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.82rem;
line-height: 1.2;
opacity: 0.84;
}
.epg-timeline {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
}
.epg-time {
font-size: 0.75rem;
line-height: 1;
opacity: 0.72;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.epg-progress-track {
position: relative;
width: 100%;
height: 6px;
overflow: hidden;
border-radius: 999px;
background-color: rgba(127, 127, 127, 0.28);
background-image: linear-gradient(
180deg,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0)
);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 1px 1px rgba(0, 0, 0, 0.18);
}
.epg-progress-fill {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
border-radius: inherit;
background-color: var(--app-selection-color);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0.18),
rgba(255, 255, 255, 0)
);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
0 0 10px -2px var(--app-selection-glow);
transition: width 0.35s ease;
}
.epg-placeholder {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.78rem;
line-height: 1.2;
opacity: 0.52;
}
.action-buttons {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 2px;
margin-inline-start: auto;
button {
width: 34px;
height: 34px;
border-radius: 10px;
color: inherit;
opacity: 0.76;
transition:
opacity 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
&:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
transform: translateY(-1px);
}
}
mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
}
.channel-list-item.active {
.epg-title,
.epg-time,
.epg-placeholder,
.drag-icon,
.action-buttons button,
.action-buttons mat-icon {
color: inherit;
}
}
@container (max-width: 360px) {
.channel-list-item {
gap: 10px;
padding-inline: 10px 8px;
}
.channel-logo-shell {
width: 40px;
height: 40px;
}
.epg-timeline {
gap: 8px;
}
.action-buttons {
gap: 0;
}
}
@container (max-width: 310px) {
.epg-timeline {
grid-template-columns: 1fr;
gap: 5px;
}
.epg-time:last-child {
display: none;
}
}
@container (max-width: 270px) {
.channel-list-item {
min-height: 52px;
gap: 8px;
padding-inline: 8px 6px;
}
.channel-logo-shell {
width: 34px;
height: 34px;
}
.epg-title,
.epg-timeline,
.epg-placeholder,
.program-info-button {
display: none;
}
}
@container (max-width: 220px) {
.channel-logo-shell,
.action-buttons {
display: none;
}
.channel-content {
gap: 0;
}
}
@@ -0,0 +1,121 @@
import { DatePipe } from '@angular/common';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { EpgProgram } from 'shared-interfaces';
import { ChannelListItemComponent } from './channel-list-item.component';
describe('ChannelListItemComponent', () => {
let fixture: ComponentFixture<ChannelListItemComponent>;
let dialog: { open: jest.Mock };
beforeEach(async () => {
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
ChannelListItemComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: dialog,
},
],
}).compileComponents();
fixture = TestBed.createComponent(ChannelListItemComponent);
});
it('formats preview times from timestamp fields when raw strings are offset', () => {
const startTimestamp = Math.floor(
Date.parse('2026-04-05T05:30:00.000Z') / 1000
);
const stopTimestamp = Math.floor(
Date.parse('2026-04-05T06:00:00.000Z') / 1000
);
const program: EpgProgram = {
start: '2026-04-05 03:00:00',
stop: '2026-04-05 03:30:00',
channel: 'channel-1',
title: 'Current Show',
desc: 'Current description',
category: null,
startTimestamp,
stopTimestamp,
};
fixture.componentRef.setInput('name', 'Cartoon Network');
fixture.componentRef.setInput('epgProgram', program);
fixture.componentRef.setInput('showProgramInfoButton', false);
fixture.detectChanges();
const times = Array.from(
fixture.nativeElement.querySelectorAll('.epg-time'),
(element: Element) => element.textContent?.trim() ?? ''
);
const datePipe = new DatePipe('en-US');
expect(times).toEqual([
datePipe.transform(startTimestamp * 1000, 'HH:mm') ?? '',
datePipe.transform(stopTimestamp * 1000, 'HH:mm') ?? '',
]);
});
it('hides the no-program placeholder for radio items without EPG data', () => {
fixture.componentRef.setInput('name', 'Radio One');
fixture.componentRef.setInput('showEpg', true);
fixture.componentRef.setInput('isRadio', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.epg-placeholder')
).toBeNull();
});
it('shows the generic fallback icon when no logo is available', () => {
fixture.componentRef.setInput('name', 'Channel Without Logo');
fixture.componentRef.setInput('logo', '');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.channel-logo-fallback')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.channel-logo')).toBeNull();
});
it('emits a context menu request on right click when details are enabled', () => {
fixture.componentRef.setInput('name', 'News One');
fixture.componentRef.setInput('showDetailsContextMenu', true);
fixture.detectChanges();
const preventDefault = jest.fn();
const stopPropagation = jest.fn();
const contextMenuRequested = jest.fn();
fixture.componentInstance.contextMenuRequested.subscribe(
contextMenuRequested
);
fixture.componentInstance.onContextMenu({
clientX: 120,
clientY: 56,
preventDefault,
stopPropagation,
} as unknown as MouseEvent);
expect(preventDefault).toHaveBeenCalled();
expect(stopPropagation).toHaveBeenCalled();
expect(contextMenuRequested).toHaveBeenCalledWith(
expect.objectContaining({
clientX: 120,
clientY: 56,
})
);
});
});
@@ -0,0 +1,111 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe, NgStyle } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
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 { TranslatePipe } from '@ngx-translate/core';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { EpgProgram } from 'shared-interfaces';
@Component({
selector: 'app-channel-list-item',
styleUrls: ['./channel-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './channel-list-item.component.html',
imports: [
DatePipe,
DragDropModule,
MatIcon,
MatIconButton,
MatTooltip,
NgStyle,
TranslatePipe,
],
})
export class ChannelListItemComponent {
private readonly dialog = inject(MatDialog);
private readonly logoFailed = signal(false);
readonly isDraggable = input(false);
readonly logo = input<string | null | undefined>('');
readonly name = input('');
readonly showFavoriteButton = input(false);
readonly showAuxActionButton = input(false);
readonly showProgramInfoButton = input(true);
readonly showDetailsContextMenu = input(false);
readonly isFavorite = input(false);
readonly selected = input(false);
readonly showEpg = input(true);
readonly isRadio = input(false);
readonly epgProgram = input<EpgProgram | null | undefined>();
/** Progress percentage pre-computed by parent for performance */
readonly progressPercentage = input(0);
readonly auxActionIcon = input('delete');
readonly auxActionTooltip = input('');
readonly clicked = output<void>();
readonly favoriteToggled = output<MouseEvent>();
readonly auxActionClicked = output<MouseEvent>();
readonly contextMenuRequested = output<MouseEvent>();
constructor() {
effect(() => {
this.logo();
this.logoFailed.set(false);
});
}
/**
* Opens the dialog with details about the current EPG program
* @param program EPG program to show details for
* @param event Mouse event to stop propagation
*/
showProgramDescription(program: EpgProgram, event: MouseEvent): void {
event.stopPropagation();
this.dialog.open(EpgItemDescriptionComponent, {
data: program,
});
}
onFavoriteClick(event: MouseEvent): void {
event.stopPropagation();
this.favoriteToggled.emit(event);
}
onAuxActionClick(event: MouseEvent): void {
event.stopPropagation();
this.auxActionClicked.emit(event);
}
onContextMenu(event: MouseEvent): void {
if (!this.showDetailsContextMenu()) {
return;
}
event.preventDefault();
event.stopPropagation();
this.contextMenuRequested.emit(event);
}
showLogoFallback(): boolean {
return !this.logo() || this.logoFailed();
}
onLogoError(event: Event): void {
this.logoFailed.set(true);
(event.target as HTMLImageElement | null)?.style.setProperty(
'display',
'none'
);
}
}
@@ -0,0 +1,17 @@
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel } from 'shared-interfaces';
export function resolveChannelLogo(
channel: Channel | null | undefined,
channelIconMap: ReadonlyMap<string, string | null | undefined>
): string {
const playlistLogo = channel?.tvg?.logo?.trim();
if (playlistLogo) {
return playlistLogo;
}
const channelId = resolveChannelEpgLookupKey(channel);
const epgIcon = channelId ? channelIconMap.get(channelId)?.trim() : '';
return epgIcon || '';
}
@@ -0,0 +1,52 @@
<div
class="channel-list"
cdkDropList
[cdkDropListDisabled]="hasSearchTerm()"
(cdkDropListDropped)="onDrop($event)"
id="favorites-list"
>
@if (enrichedFavorites().length > 0) {
@for (
channel of enrichedFavorites();
track trackByFn($index, channel);
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[isDraggable]="!hasSearchTerm()"
[logo]="channel.logo"
[showEpg]="shouldShowEpg()"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
(clicked)="onChannelClick(channel)"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="true"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
}
} @else {
<div class="empty-favorites">
<strong>
@if (hasSearchTerm()) {
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
} @else {
{{ 'CHANNELS.NO_FAVORITES' | translate }}
}
</strong>
</div>
<div class="empty-favorites-hint">
@if (hasSearchTerm()) {
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
} @else {
{{ 'CHANNELS.USE_STAR_TO_FAVORITE' | translate }}
}
</div>
}
</div>
@@ -0,0 +1,35 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
#favorites-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
.channel-list {
overflow-x: hidden;
}
.empty-favorites,
.empty-favorites-hint {
padding: 16px 24px;
text-align: center;
}
.empty-favorites {
margin-top: 40px;
font-size: 1.05rem;
font-weight: 520;
letter-spacing: -0.01em;
}
.empty-favorites-hint {
opacity: 0.65;
font-size: 0.85rem;
}
@@ -0,0 +1,136 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@Component({
selector: 'app-favorites-view',
templateUrl: './favorites-view.component.html',
styleUrls: ['./favorites-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChannelListItemComponent, DragDropModule, TranslatePipe],
})
export class FavoritesViewComponent {
/** Favorite channels */
readonly favorites = input.required<Channel[]>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** Emits when favorite is toggled (removed) */
readonly favoriteToggled = output<{
channel: Channel;
event: MouseEvent;
}>();
/** Emits when favorites order changes via drag-drop */
readonly favoritesReordered = output<string[]>();
readonly hasSearchTerm = computed(() => this.searchTerm().trim().length > 0);
readonly filteredFavorites = computed(() => {
const favorites = this.favorites();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return favorites;
}
return favorites.filter((channel) =>
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
.toLowerCase()
.includes(term)
);
});
/**
* Computed signal for enriched favorites with EPG data
*/
readonly enrichedFavorites = computed(() => {
const favorites = this.filteredFavorites();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
// Read progressTick to trigger recalculation
this.progressTick();
return favorites.map((channel) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
});
/**
* Calculates progress percentage for an EPG program
*/
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = new Date().getTime();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
trackByFn(_: number, channel: Channel): string {
return channel?.id;
}
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggle(channel: Channel, event: MouseEvent): void {
this.favoriteToggled.emit({ channel, event });
}
onDrop(event: CdkDragDrop<Channel[]>): void {
if (this.hasSearchTerm()) {
return;
}
const favorites = [...this.favorites()];
moveItemInArray(favorites, event.previousIndex, event.currentIndex);
this.favoritesReordered.emit(favorites.map((item) => item.url));
}
}
@@ -0,0 +1,85 @@
<h2 mat-dialog-title>{{ 'CHANNELS.GROUP_MANAGEMENT.TITLE' | translate }}</h2>
<mat-dialog-content>
<div class="header-actions">
<div class="selection-info">
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
</div>
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="allSelected()"
>
<mat-icon>check_box</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.SELECT_ALL' | translate }}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="selectedCount() === 0"
>
<mat-icon>check_box_outline_blank</mat-icon>
{{ 'CHANNELS.GROUP_MANAGEMENT.DESELECT_ALL' | translate }}
</button>
</div>
</div>
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
<input
type="search"
spellcheck="false"
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
[placeholder]="
'CHANNELS.GROUP_MANAGEMENT.SEARCH_PLACEHOLDER' | translate
"
/>
@if (searchTerm()) {
<button
type="button"
mat-icon-button
class="search-inline__clear"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
</button>
}
</label>
<div class="groups-list">
@for (group of filteredGroups(); track group.key) {
<div class="group-item" (click)="toggleGroup(group)">
<mat-checkbox
[checked]="group.selected"
(click)="$event.stopPropagation()"
(change)="toggleGroup(group)"
>
</mat-checkbox>
<span class="group-name">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="item-count">({{ group.count }})</span>
</div>
} @empty {
<div class="empty-message">
{{ 'CHANNELS.GROUP_MANAGEMENT.NO_RESULTS' | translate }}
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="cancel()">
{{ 'CLOSE' | translate }}
</button>
<button mat-flat-button color="primary" (click)="save()">
{{ 'CHANNELS.GROUP_MANAGEMENT.SAVE' | translate }}
</button>
</mat-dialog-actions>
@@ -0,0 +1,150 @@
.mat-mdc-dialog-content {
display: flex;
gap: 10px;
flex-direction: column;
overflow: hidden !important;
min-width: 480px;
max-width: 600px;
max-height: 85vh !important;
}
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.selection-info {
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
strong {
color: var(--mat-sys-primary);
}
}
.bulk-actions {
display: flex;
gap: 8px;
button {
font-size: 12px;
}
}
.search-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
min-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));
cursor: text;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
}
.search-inline__icon {
width: 18px;
height: 18px;
font-size: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.search-inline input {
flex: 1;
width: 100%;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
}
&::-webkit-search-cancel-button {
display: none;
}
}
.search-inline__clear {
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
line-height: 24px;
flex-shrink: 0;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
}
.groups-list {
flex: 1;
min-height: 200px;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
}
.group-item {
display: flex;
align-items: center;
gap: 6px;
min-height: 30px;
padding: 0 10px;
cursor: pointer;
transition: background-color 0.12s ease;
&:hover {
background-color: var(--mat-sys-surface-container);
}
&:not(:last-child) {
border-bottom: 1px solid var(--mat-sys-surface-container);
}
mat-checkbox {
--mdc-checkbox-state-layer-size: 28px;
}
}
.group-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8125rem;
}
.item-count {
font-size: 0.8rem;
color: var(--mat-sys-on-surface-variant);
}
.empty-message {
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8125rem;
}
@@ -0,0 +1,120 @@
import { TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
export interface GroupManagementDialogGroup {
readonly count: number;
readonly key: string;
}
export interface GroupManagementDialogData {
readonly groups: GroupManagementDialogGroup[];
readonly hiddenGroupTitles: string[];
}
interface GroupWithSelection extends GroupManagementDialogGroup {
readonly selected: boolean;
}
@Component({
selector: 'app-group-management-dialog',
imports: [
MatDialogModule,
MatButtonModule,
MatCheckboxModule,
MatIconModule,
TitleCasePipe,
TranslatePipe,
],
templateUrl: './group-management-dialog.component.html',
styleUrl: './group-management-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GroupManagementDialogComponent {
private readonly dialogRef = inject(
MatDialogRef<GroupManagementDialogComponent, string[] | undefined>
);
readonly data = inject<GroupManagementDialogData>(MAT_DIALOG_DATA);
readonly searchTerm = signal('');
readonly groups = signal<GroupWithSelection[]>(
this.data.groups.map((group) => ({
...group,
selected: !this.data.hiddenGroupTitles.includes(group.key),
}))
);
readonly filteredGroups = computed(() => {
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return this.groups();
}
return this.groups().filter((group) =>
group.key.toLowerCase().includes(term)
);
});
readonly selectedCount = computed(
() => this.groups().filter((group) => group.selected).length
);
readonly totalCount = computed(() => this.groups().length);
readonly allSelected = computed(
() =>
this.groups().length > 0 &&
this.groups().every((group) => group.selected)
);
clearSearch(): void {
this.searchTerm.set('');
}
toggleGroup(group: GroupWithSelection): void {
this.groups.update((groups) =>
groups.map((current) =>
current.key === group.key
? { ...current, selected: !current.selected }
: current
)
);
}
selectAll(): void {
this.groups.update((groups) =>
groups.map((group) => ({ ...group, selected: true }))
);
}
deselectAll(): void {
this.groups.update((groups) =>
groups.map((group) => ({ ...group, selected: false }))
);
}
save(): void {
const hiddenGroupTitles = this.groups()
.filter((group) => !group.selected)
.map((group) => group.key);
this.dialogRef.close(hiddenGroupTitles);
}
cancel(): void {
this.dialogRef.close(undefined);
}
}
@@ -0,0 +1,295 @@
@if (!hasAnyGroups()) {
<div class="groups-view-empty-state">
<mat-icon class="empty-state-icon">{{
searchTerm() ? 'search_off' : 'folder_off'
}}</mat-icon>
<div class="empty-state-title">
{{
(searchTerm()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
) | translate
}}
</div>
<div class="empty-state-hint">
{{
(searchTerm()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
) | translate
}}
</div>
</div>
} @else {
<div class="groups-view-layout">
<aside
class="groups-nav-panel"
appResizable
[minWidth]="164"
[maxWidth]="320"
[defaultWidth]="208"
storageKey="m3u-groups-nav-width"
(resizeStart)="onGroupsNavResizeStart()"
(widthChange)="onGroupsNavWidthChange($event)"
(resizeEnd)="onGroupsNavResizeEnd($event)"
>
<div class="groups-nav-shell">
<header class="groups-nav-header">
<div class="groups-nav-header__content">
<h2 class="groups-nav-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<div class="groups-nav-actions">
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--search"
[matTooltip]="'CHANNELS.SEARCH_GROUPS' | translate"
[attr.aria-pressed]="
isGroupSearchOpen() ? 'true' : 'false'
"
[attr.aria-label]="
'CHANNELS.SEARCH_GROUPS' | translate
"
(click)="toggleGroupSearch()"
>
<mat-icon>search</mat-icon>
</button>
<button
type="button"
mat-icon-button
class="groups-nav-action groups-nav-action--manage"
[matTooltip]="'CHANNELS.MANAGE_GROUPS' | translate"
[attr.aria-label]="
'CHANNELS.MANAGE_GROUPS' | translate
"
(click)="openGroupManagement()"
>
<mat-icon>tune</mat-icon>
</button>
</div>
</div>
@if (isGroupSearchOpen()) {
<label class="groups-nav-search">
<mat-icon class="groups-nav-search__icon">
search
</mat-icon>
<input
#groupSearchInput
type="search"
spellcheck="false"
[value]="localGroupSearchTerm()"
[placeholder]="
'CHANNELS.SEARCH_GROUPS' | translate
"
(input)="
updateGroupSearchTerm(
$any($event.target).value
)
"
(keydown.escape)="closeGroupSearch()"
/>
<button
type="button"
mat-icon-button
class="groups-nav-search__close"
[attr.aria-label]="'CLOSE' | translate"
(click)="closeGroupSearch()"
>
<mat-icon>close</mat-icon>
</button>
</label>
}
</header>
<div class="groups-nav-list nav-list">
@if (filteredGroups().length > 0) {
@for (
group of filteredGroups();
track trackByGroupKey($index, group)
) {
<button
type="button"
class="nav-item group-nav-item"
[attr.data-group-key]="group.key"
[class.selected]="
selectedGroupKey() === group.key
"
[attr.aria-current]="
selectedGroupKey() === group.key
? 'true'
: null
"
(click)="selectGroup(group.key)"
>
<span class="nav-item-label">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</span>
<span class="nav-item-meta">
{{ group.count }}
</span>
</button>
}
} @else {
<div class="groups-nav-empty">
{{
(hasSearchQuery()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
}}
</div>
}
</div>
</div>
</aside>
<section class="sidebar groups-content-panel">
@if (selectedGroup(); as group) {
<header class="sidebar-header groups-content-header">
<div class="category-meta">
<h2 class="category-title">
@if (group.key) {
{{ group.key | titlecase }}
} @else {
{{ 'CHANNELS.UNGROUPED' | translate }}
}
</h2>
<span class="category-subtitle">
{{ group.count }}
{{ group.count === 1 ? 'item' : 'items' }}
</span>
</div>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
[matTooltip]="'Sort: ' + groupChannelSortLabel()"
aria-label="Sort channels"
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
(click)="setGroupChannelSortMode('server')"
>
<span>Server Order</span>
@if (groupChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setGroupChannelSortMode('name-asc')"
>
<span>Name A-Z</span>
@if (groupChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button
mat-menu-item
(click)="setGroupChannelSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (groupChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
}
</button>
</mat-menu>
</header>
<cdk-virtual-scroll-viewport
[itemSize]="itemSize()"
class="groups-channels-viewport"
>
<app-channel-list-item
*cdkVirtualFor="
let channel of selectedGroupChannels();
index as i;
trackBy: trackByChannel
"
[name]="
i +
1 +
'. ' +
(channel?.name ||
('CHANNELS.UNNAMED_CHANNEL' | translate))
"
[logo]="channel.logo"
[showEpg]="shouldShowEpg()"
[showDetailsContextMenu]="true"
[isRadio]="channel?.radio === 'true'"
[epgProgram]="channel.epgProgram"
[progressPercentage]="channel.progressPercentage"
[selected]="activeChannelUrl() === channel?.url"
[showFavoriteButton]="true"
[isFavorite]="
channel?.url
? favoriteIds().has(channel.url)
: false
"
(clicked)="onChannelClick(channel)"
(contextMenuRequested)="
onChannelContextMenu(channel, $event)
"
(favoriteToggled)="onFavoriteToggle(channel, $event)"
/>
</cdk-virtual-scroll-viewport>
} @else {
<header class="sidebar-header groups-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</div>
</header>
<div class="groups-content-empty-state">
<mat-icon class="empty-state-icon">
{{
hasSearchQuery() ? 'search_off' : 'visibility_off'
}}
</mat-icon>
<div class="empty-state-title">
{{
(hasSearchQuery()
? 'CHANNELS.NO_SEARCH_RESULTS'
: 'CHANNELS.NO_VISIBLE_GROUPS'
) | translate
}}
</div>
<div class="empty-state-hint">
{{
(hasSearchQuery()
? 'CHANNELS.TRY_DIFFERENT_SEARCH'
: 'CHANNELS.MANAGE_GROUPS_HINT'
) | translate
}}
</div>
</div>
}
</section>
</div>
}
<div
aria-hidden="true"
class="channel-context-menu-anchor"
[style.left]="contextMenuPosition().x"
[style.top]="contextMenuPosition().y"
[matMenuTriggerFor]="channelContextMenu"
#contextMenuTrigger="matMenuTrigger"
></div>
<mat-menu #channelContextMenu="matMenu">
<button mat-menu-item (click)="openChannelDetails()">
<mat-icon>info</mat-icon>
<span>{{ 'CHANNELS.SHOW_DETAILS' | translate }}</span>
</button>
</mat-menu>
@@ -0,0 +1,284 @@
@use '@angular/material' as mat;
@use '../../../../../styles/portal-sidebar';
@use '../../../../../styles/panel-header' as panel;
@use '../../../../../../../apps/web/src/nav-list.scss';
:host {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.groups-view-layout {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--app-content-bg, var(--mat-sys-surface));
}
.groups-nav-panel {
width: 208px;
min-width: 164px;
max-width: 320px;
flex-shrink: 0;
min-height: 0;
display: flex;
overflow: hidden;
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
padding: 12px 10px 10px 12px;
box-sizing: border-box;
container-type: inline-size;
}
.groups-nav-shell {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.groups-nav-header {
padding: 4px 6px 12px;
flex-shrink: 0;
display: flex;
gap: 10px;
flex-direction: column;
}
.groups-nav-header__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.groups-nav-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
.groups-nav-actions {
display: flex;
align-items: center;
gap: 4px;
}
.groups-nav-action,
.groups-nav-search {
@include mat.form-field-density(-5);
}
.groups-nav-action {
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
line-height: 28px;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
}
.groups-nav-search {
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
min-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));
cursor: text;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
}
.groups-nav-search__icon {
width: 18px;
height: 18px;
font-size: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
.groups-nav-search input {
flex: 1;
min-width: 0;
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.84rem;
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
}
&::-webkit-search-cancel-button {
display: none;
}
}
.groups-nav-search__close {
width: 24px;
height: 24px;
min-width: 24px;
line-height: 24px;
flex-shrink: 0;
padding: 0;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
}
.groups-content-header {
@include panel.standard-panel-header();
border-top-left-radius: inherit;
}
.groups-content-header button {
@include mat.form-field-density(-5);
}
.category-meta {
@include panel.standard-panel-meta();
}
.category-title {
@include panel.standard-panel-title(0.94rem);
}
.category-subtitle {
@include panel.standard-panel-subtitle();
}
.groups-nav-list {
height: 100%;
min-height: 0;
padding-top: 0;
padding-inline: 0 2px;
}
.groups-nav-empty {
padding: 16px 12px;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.82rem;
line-height: 1.4;
}
.group-nav-item {
background: transparent;
}
.channel-context-menu-anchor {
position: fixed;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.groups-content-panel {
width: auto;
min-width: 0;
max-width: none;
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: none;
background: var(--app-content-bg, var(--mat-sys-surface));
border-top-left-radius: 16px;
}
.groups-content-panel > .groups-content-header {
border-top-left-radius: inherit;
}
.groups-channels-viewport {
flex: 1;
min-height: 0;
width: 100%;
padding: 4px 8px 12px;
box-sizing: border-box;
::ng-deep .cdk-virtual-scroll-content-wrapper {
width: 100% !important;
max-width: 100% !important;
}
}
.groups-content-empty-state {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
text-align: center;
}
.groups-view-empty-state {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
text-align: center;
}
.empty-state-icon {
width: 48px;
height: 48px;
font-size: 48px;
opacity: 0.5;
margin-bottom: 16px;
}
.empty-state-title {
font-size: 1.05rem;
font-weight: 520;
letter-spacing: -0.01em;
margin-bottom: 8px;
}
.empty-state-hint {
opacity: 0.65;
font-size: 0.85rem;
max-width: 240px;
line-height: 1.4;
}
@container (max-width: 220px) {
.groups-nav-list {
padding-inline-end: 0;
}
}
@@ -0,0 +1,609 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { Channel } from 'shared-interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { GroupManagementDialogComponent } from './group-management-dialog/group-management-dialog.component';
import { GroupsViewComponent } from './groups-view.component';
const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode';
function createChannel(
id: string,
name: string,
url: string,
groupTitle: string
): Channel {
return {
epgParams: '',
group: {
title: groupTitle,
},
http: {
origin: '',
referrer: '',
'user-agent': '',
},
id,
name,
radio: 'false',
tvg: {
id: `${id}-tvg`,
logo: '',
name,
rec: '',
url: '',
},
url,
} as Channel;
}
describe('GroupsViewComponent', () => {
let fixture: ComponentFixture<GroupsViewComponent>;
let component: GroupsViewComponent;
let dialog: { open: jest.Mock };
const sportsCenter = createChannel(
'sports-1',
'Sports Center',
'http://example.com/sports-center.m3u8',
'Sports'
);
const matchNight = createChannel(
'sports-2',
'Match Night',
'http://example.com/match-night.m3u8',
'Sports'
);
const worldUpdate = createChannel(
'news-1',
'World Update',
'http://example.com/world-update.m3u8',
'News'
);
const dailyBulletin = createChannel(
'news-2',
'Daily Bulletin',
'http://example.com/daily-bulletin.m3u8',
'News'
);
const movieClassic = createChannel(
'movies-1',
'Movie Classic',
'http://example.com/movie-classic.m3u8',
'Movies'
);
const scienceNow = createChannel(
'science-1',
'Science Now',
'http://example.com/science-now.m3u8',
'Series'
);
const groupedChannels: Record<string, Channel[]> = {
Movies: [movieClassic],
News: [worldUpdate, dailyBulletin],
Series: [scienceNow],
Sports: [sportsCenter, matchNight],
};
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: jest.fn(),
writable: true,
});
});
beforeEach(async () => {
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
dialog = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [
GroupsViewComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MatDialog,
useValue: dialog,
},
],
}).compileComponents();
createComponent();
});
afterEach(() => {
fixture.destroy();
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
});
function createComponent(
overrides: Partial<{
activeChannelUrl: string | undefined;
favoriteIds: Set<string>;
groupedChannels: Record<string, Channel[]>;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
sidebarWidth: number | null;
shouldShowEpg: boolean;
}> = {}
): void {
fixture = TestBed.createComponent(GroupsViewComponent);
component = fixture.componentInstance;
setInputs(overrides);
}
function setInputs(
overrides: Partial<{
activeChannelUrl: string | undefined;
favoriteIds: Set<string>;
groupedChannels: Record<string, Channel[]>;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
sidebarWidth: number | null;
shouldShowEpg: boolean;
}> = {}
): void {
fixture.componentRef.setInput(
'groupedChannels',
overrides.groupedChannels ?? groupedChannels
);
fixture.componentRef.setInput('searchTerm', overrides.searchTerm ?? '');
fixture.componentRef.setInput('channelEpgMap', new Map<string, null>());
fixture.componentRef.setInput(
'channelIconMap',
new Map<string, string>()
);
fixture.componentRef.setInput(
'progressTick',
overrides.progressTick ?? 0
);
fixture.componentRef.setInput(
'shouldShowEpg',
overrides.shouldShowEpg ?? true
);
fixture.componentRef.setInput(
'activeChannelUrl',
overrides.activeChannelUrl
);
fixture.componentRef.setInput(
'favoriteIds',
overrides.favoriteIds ?? new Set<string>()
);
fixture.componentRef.setInput(
'hiddenGroupTitles',
overrides.hiddenGroupTitles ?? []
);
fixture.componentRef.setInput(
'sidebarWidth',
overrides.sidebarWidth ?? 460
);
fixture.detectChanges();
}
it('sorts groups with numeric buckets before alphabetic buckets', () => {
setInputs({
groupedChannels: {
'Group 10': [sportsCenter],
'Group 2': [movieClassic],
Alpha: [scienceNow],
},
});
expect(component.filteredGroups().map((group) => group.key)).toEqual([
'Group 2',
'Group 10',
'Alpha',
]);
});
it('defaults to server order when no saved sort mode exists', () => {
expect(component.groupChannelSortMode()).toBe('server');
expect(component.groupChannelSortLabel()).toBe('Server Order');
});
it('restores a saved valid sort mode and ignores invalid stored values', () => {
fixture.destroy();
localStorage.setItem(GROUP_CHANNEL_SORT_STORAGE_KEY, 'name-asc');
createComponent();
expect(component.groupChannelSortMode()).toBe('name-asc');
expect(component.groupChannelSortLabel()).toBe('Name A-Z');
fixture.destroy();
localStorage.setItem(GROUP_CHANNEL_SORT_STORAGE_KEY, 'invalid');
createComponent();
expect(component.groupChannelSortMode()).toBe('server');
});
it('falls back to an EPG icon for grouped channels without a playlist logo', () => {
const channelWithoutLogo = {
...sportsCenter,
tvg: {
...sportsCenter.tvg,
logo: '',
},
};
setInputs({
groupedChannels: {
Sports: [channelWithoutLogo],
},
});
fixture.componentRef.setInput(
'channelIconMap',
new Map([
[channelWithoutLogo.tvg.id, 'https://example.com/sports.png'],
])
);
fixture.detectChanges();
expect(component.selectedGroupChannels()[0].logo).toBe(
'https://example.com/sports.png'
);
});
it('persists sort mode changes', () => {
component.setGroupChannelSortMode('name-desc');
expect(component.groupChannelSortMode()).toBe('name-desc');
expect(localStorage.getItem(GROUP_CHANNEL_SORT_STORAGE_KEY)).toBe(
'name-desc'
);
});
it('sorts selected group channels by server order, name ascending, and name descending', () => {
const alphaSignal = createChannel(
'sort-1',
'Alpha Signal',
'http://example.com/alpha-signal.m3u8',
'Sorted'
);
const zuluVision = createChannel(
'sort-2',
'Zulu Vision',
'http://example.com/zulu-vision.m3u8',
'Sorted'
);
const middleNews = createChannel(
'sort-3',
'Middle News',
'http://example.com/middle-news.m3u8',
'Sorted'
);
setInputs({
groupedChannels: {
Sorted: [zuluVision, alphaSignal, middleNews],
},
});
expect(
component.selectedGroupChannels().map((channel) => channel.name)
).toEqual(['Zulu Vision', 'Alpha Signal', 'Middle News']);
component.setGroupChannelSortMode('name-asc');
fixture.detectChanges();
expect(
component.selectedGroupChannels().map((channel) => channel.name)
).toEqual(['Alpha Signal', 'Middle News', 'Zulu Vision']);
component.setGroupChannelSortMode('name-desc');
fixture.detectChanges();
expect(
component.selectedGroupChannels().map((channel) => channel.name)
).toEqual(['Zulu Vision', 'Middle News', 'Alpha Signal']);
});
it('prefers the active channel group for initial selection', () => {
setInputs({ activeChannelUrl: worldUpdate.url });
expect(component.selectedGroupKey()).toBe('News');
expect(component.selectedGroup()?.key).toBe('News');
});
it('retains a visible manual selection and falls back to the first visible group', () => {
component.selectGroup('Movies');
fixture.detectChanges();
setInputs({
activeChannelUrl: sportsCenter.url,
searchTerm: 'movie',
});
expect(component.selectedGroupKey()).toBe('Movies');
setInputs({
activeChannelUrl: sportsCenter.url,
searchTerm: 'science',
});
expect(component.selectedGroupKey()).toBe('Series');
});
it('switches selection to the active channel group when playback changes', () => {
component.selectGroup('Movies');
fixture.detectChanges();
setInputs({ activeChannelUrl: sportsCenter.url });
expect(component.selectedGroupKey()).toBe('Sports');
setInputs({ activeChannelUrl: scienceNow.url });
expect(component.selectedGroupKey()).toBe('Series');
});
it('keeps group selection behavior unchanged when channel sort mode changes', () => {
component.setGroupChannelSortMode('name-asc');
component.selectGroup('Movies');
fixture.detectChanges();
setInputs({ activeChannelUrl: sportsCenter.url });
expect(component.selectedGroupKey()).toBe('Sports');
expect(component.filteredGroups().map((group) => group.key)).toEqual([
'Movies',
'News',
'Series',
'Sports',
]);
});
it('matches group titles as full-group results and channel names as filtered results', () => {
setInputs({ searchTerm: 'news' });
expect(component.filteredGroups()).toEqual([
expect.objectContaining({
count: 2,
key: 'News',
titleMatches: true,
}),
]);
expect(
component.selectedGroupChannels().map((channel) => channel.name)
).toEqual(['World Update', 'Daily Bulletin']);
setInputs({ searchTerm: 'update' });
expect(component.filteredGroups()).toEqual([
expect.objectContaining({
count: 1,
key: 'News',
titleMatches: false,
}),
]);
expect(
component.selectedGroupChannels().map((channel) => channel.name)
).toEqual(['World Update']);
});
it('filters hidden groups from the rail and selected pane', () => {
setInputs({
activeChannelUrl: worldUpdate.url,
hiddenGroupTitles: ['News', 'Sports'],
});
expect(component.filteredGroups().map((group) => group.key)).toEqual([
'Movies',
'Series',
]);
expect(component.selectedGroupKey()).toBe('Movies');
expect(
component.selectedGroupChannels().map((channel) => channel.name)
).toEqual(['Movie Classic']);
});
it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', () => {
const hiddenGroupTitlesChanged = jest.fn();
component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged);
dialog.open.mockReturnValue({
afterClosed: () => of(['News', 'Sports']),
});
component.openGroupManagement();
expect(dialog.open).toHaveBeenCalledWith(
GroupManagementDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
hiddenGroupTitles: [],
groups: expect.arrayContaining([
{ key: 'Movies', count: 1 },
{ key: 'News', count: 2 },
{ key: 'Series', count: 1 },
{ key: 'Sports', count: 2 },
]),
}),
maxHeight: '90vh',
width: '500px',
})
);
expect(hiddenGroupTitlesChanged).toHaveBeenCalledWith([
'News',
'Sports',
]);
});
it('toggles the inline group search from the header action and filters the visible groups', () => {
const searchButton = fixture.nativeElement.querySelector(
'.groups-nav-action--search'
) as HTMLButtonElement;
searchButton.click();
fixture.detectChanges();
const searchInput = fixture.nativeElement.querySelector(
'.groups-nav-search input'
) as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
searchInput!.value = 'spo';
searchInput!.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(component.filteredGroups().map((group) => group.key)).toEqual([
'Sports',
]);
expect(component.selectedGroupKey()).toBe('Sports');
searchButton.click();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.groups-nav-search input')
).toBeNull();
expect(component.filteredGroups().map((group) => group.key)).toEqual([
'Movies',
'News',
'Series',
'Sports',
]);
});
it('emits channel and favorite events from the selected group pane', () => {
const channelSelected = jest.fn();
const favoriteToggled = jest.fn();
const clickEvent = new MouseEvent('click');
component.channelSelected.subscribe(channelSelected);
component.favoriteToggled.subscribe(favoriteToggled);
component.onChannelClick(movieClassic);
component.onFavoriteToggle(movieClassic, clickEvent);
expect(channelSelected).toHaveBeenCalledWith(movieClassic);
expect(favoriteToggled).toHaveBeenCalledWith({
channel: movieClassic,
event: clickEvent,
});
});
it('positions the context menu at the viewport click and opens channel details from the selected group pane', async () => {
const openMenuSpy = jest
.spyOn(component.contextMenuTrigger(), 'openMenu')
.mockImplementation();
component.onChannelContextMenu(movieClassic, {
clientX: 212,
clientY: 264,
} as MouseEvent);
await Promise.resolve();
expect(component.contextMenuChannel()).toBe(movieClassic);
expect(component.contextMenuPosition()).toEqual({
x: '212px',
y: '264px',
});
expect(openMenuSpy).toHaveBeenCalled();
component.openChannelDetails();
expect(dialog.open).toHaveBeenCalledWith(
ChannelDetailsDialogComponent,
expect.objectContaining({
data: movieClassic,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
})
);
});
it('emits total sidebar width requests while resizing the groups rail', () => {
const requested = jest.fn();
const committed = jest.fn();
let contentWidth = 252;
component.sidebarWidthRequested.subscribe(requested);
component.sidebarWidthRequestEnded.subscribe(committed);
const contentPanel = fixture.nativeElement.querySelector(
'.groups-content-panel'
) as HTMLElement;
jest.spyOn(contentPanel, 'getBoundingClientRect').mockImplementation(
() =>
({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: contentWidth,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect
);
component.onGroupsNavResizeStart();
contentWidth = 120;
component.onGroupsNavWidthChange(260);
component.onGroupsNavResizeEnd(260);
expect(requested).toHaveBeenCalledWith(512);
expect(committed).toHaveBeenCalledWith(512);
});
it('keeps the layout visible for searches without matches', () => {
setInputs({ searchTerm: 'zzz' });
const layout = fixture.nativeElement.querySelector(
'.groups-view-layout'
) as HTMLElement | null;
const emptyState = fixture.nativeElement.querySelector(
'.groups-content-empty-state'
) as HTMLElement | null;
const manageButton = fixture.nativeElement.querySelector(
'.groups-nav-action--manage'
) as HTMLButtonElement | null;
expect(layout).not.toBeNull();
expect(emptyState).not.toBeNull();
expect(emptyState?.textContent).toContain('CHANNELS.NO_SEARCH_RESULTS');
expect(manageButton).not.toBeNull();
});
it('renders the empty-category state when no grouped channels exist', () => {
setInputs({ groupedChannels: {} });
const emptyState = fixture.nativeElement.querySelector(
'.groups-view-empty-state'
) as HTMLElement | null;
expect(emptyState).not.toBeNull();
expect(emptyState?.textContent).toContain(
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
);
});
it('keeps the manage action visible when all groups are hidden', () => {
setInputs({
hiddenGroupTitles: ['Movies', 'News', 'Series', 'Sports'],
});
const layout = fixture.nativeElement.querySelector(
'.groups-view-layout'
) as HTMLElement | null;
const emptyState = fixture.nativeElement.querySelector(
'.groups-content-empty-state'
) as HTMLElement | null;
const manageButton = fixture.nativeElement.querySelector(
'.groups-nav-action--manage'
) as HTMLButtonElement | null;
expect(layout).not.toBeNull();
expect(emptyState?.textContent).toContain('CHANNELS.NO_VISIBLE_GROUPS');
expect(manageButton).not.toBeNull();
});
});
@@ -0,0 +1,590 @@
import { KeyValue, TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
ElementRef,
OutputEmitterRef,
computed,
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import {
PortalChannelSortMode,
getPortalChannelSortModeLabel,
persistPortalChannelSortMode,
restorePortalChannelSortMode,
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
import { EnrichedChannel } from '../all-channels-view/all-channels-view.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
import { ResizableDirective } from '../../resizable/resizable.directive';
import {
GroupManagementDialogComponent,
GroupManagementDialogGroup,
} from './group-management-dialog/group-management-dialog.component';
const GROUP_CHANNEL_SORT_STORAGE_KEY = 'm3u-groups-channel-sort-mode';
interface GroupView {
readonly channels: Channel[];
readonly count: number;
readonly key: string;
}
interface FilteredGroupView {
readonly channels: Channel[];
readonly count: number;
readonly key: string;
readonly titleMatches: boolean;
}
@Component({
selector: 'app-groups-view',
templateUrl: './groups-view.component.html',
styleUrls: ['./groups-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
MatButtonModule,
MatIconModule,
MatMenuModule,
MatTooltipModule,
ResizableDirective,
ScrollingModule,
TitleCasePipe,
TranslatePipe,
],
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly contextMenuTrigger =
viewChild.required<MatMenuTrigger>('contextMenuTrigger');
readonly groupSearchInput =
viewChild<ElementRef<HTMLInputElement>>('groupSearchInput');
/** Grouped channels object */
readonly groupedChannels = input.required<{ [key: string]: Channel[] }>();
readonly searchTerm = input('');
/** EPG map for channel enrichment */
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
/** Progress tick to trigger progress recalculation */
readonly progressTick = input.required<number>();
/** Whether to show EPG data */
readonly shouldShowEpg = input.required<boolean>();
/** Currently active channel URL */
readonly activeChannelUrl = input<string | undefined>();
/** Set of favorite channel URLs */
readonly favoriteIds = input<Set<string>>(new Set());
readonly hiddenGroupTitles = input<string[]>([]);
/** Current outer sidebar width */
readonly sidebarWidth = input<number | null>(null);
/** Emits when a channel is selected */
readonly channelSelected = output<Channel>();
/** Emits when favorite is toggled */
readonly favoriteToggled = output<{
channel: Channel;
event: MouseEvent;
}>();
/** Emits while the groups rail requests a larger total sidebar width */
readonly sidebarWidthRequested = output<number>();
/** Emits when the groups rail resize ends */
readonly sidebarWidthRequestEnded = output<number>();
readonly hiddenGroupTitlesChanged = output<string[]>();
readonly isGroupSearchOpen = signal(false);
readonly localGroupSearchTerm = signal('');
readonly selectedGroupKey = signal<string | null>(null);
readonly groupChannelSortMode = signal<PortalChannelSortMode>(
restorePortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY)
);
readonly groupChannelSortLabel = computed(() =>
getPortalChannelSortModeLabel(this.groupChannelSortMode())
);
readonly hasSearchQuery = computed(
() =>
this.searchTerm().trim().length > 0 ||
this.localGroupSearchTerm().trim().length > 0
);
readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48));
readonly contextMenuChannel = signal<Channel | null>(null);
readonly contextMenuPosition = signal({
x: '0px',
y: '0px',
});
private previousActiveChannelUrl: string | undefined;
private preservedContentWidth = 0;
constructor() {
effect(() => {
const filteredGroups = this.filteredGroups();
const visibleGroupKeys = new Set(
filteredGroups.map((group) => group.key)
);
const currentSelection = this.selectedGroupKey();
const activeGroupKey = this.activeChannelGroupKey();
const activeChannelUrl = this.activeChannelUrl();
const activeChannelChanged =
activeChannelUrl !== this.previousActiveChannelUrl;
this.previousActiveChannelUrl = activeChannelUrl;
let nextSelection: string | null = null;
if (
activeChannelChanged &&
activeGroupKey &&
visibleGroupKeys.has(activeGroupKey)
) {
nextSelection = activeGroupKey;
} else if (
currentSelection &&
visibleGroupKeys.has(currentSelection)
) {
nextSelection = currentSelection;
} else if (activeGroupKey && visibleGroupKeys.has(activeGroupKey)) {
nextSelection = activeGroupKey;
} else {
nextSelection = filteredGroups[0]?.key ?? null;
}
if (nextSelection !== currentSelection) {
this.selectedGroupKey.set(nextSelection);
}
});
effect(() => {
const selectedGroupKey = this.selectedGroupKey();
if (selectedGroupKey == null) {
return;
}
queueMicrotask(() => {
const container = this.hostEl.nativeElement.querySelector(
'.groups-nav-list'
) as HTMLElement | null;
const candidates = Array.from(
this.hostEl.nativeElement.querySelectorAll(
'[data-group-key]'
)
) as HTMLElement[];
const selected =
candidates.find(
(candidate) =>
candidate.dataset['groupKey'] === selectedGroupKey
) ?? null;
if (!container || !selected) {
return;
}
const containerRect = container.getBoundingClientRect();
const selectedRect = selected.getBoundingClientRect();
const targetTop =
container.scrollTop +
(selectedRect.top - containerRect.top) -
container.clientHeight / 2 +
selectedRect.height / 2;
const maxScrollTop = Math.max(
0,
container.scrollHeight - container.clientHeight
);
container.scrollTo({
behavior: 'smooth',
top: Math.min(maxScrollTop, Math.max(0, targetTop)),
});
});
});
effect(() => {
if (!this.isGroupSearchOpen()) {
return;
}
queueMicrotask(() => {
this.groupSearchInput()?.nativeElement.focus();
});
});
}
readonly allGroups = computed<GroupView[]>(() => {
const grouped = this.groupedChannels();
const groups = Object.entries(grouped).map(([key, channels]) => ({
channels,
count: channels.length,
key,
}));
return groups.sort(this.groupsComparator);
});
readonly visibleGroups = computed(() => {
const hiddenGroupTitles = new Set(this.hiddenGroupTitles());
return this.allGroups().filter(
(group) =>
!hiddenGroupTitles.has(group.key) && group.channels.length > 0
);
});
readonly workspaceFilteredGroups = computed<FilteredGroupView[]>(() => {
const term = this.searchTerm().trim().toLowerCase();
const groups = this.visibleGroups();
if (!term) {
return groups
.map((group) => ({
channels: group.channels,
count: group.count,
key: group.key,
titleMatches: false,
}));
}
return groups.reduce<FilteredGroupView[]>((acc, group) => {
const titleMatches = group.key.toLowerCase().includes(term);
const channels = titleMatches
? group.channels
: group.channels.filter((channel) =>
`${channel.name ?? ''}`.toLowerCase().includes(term)
);
if (channels.length === 0) {
return acc;
}
acc.push({
channels,
count: channels.length,
key: group.key,
titleMatches,
});
return acc;
}, []);
});
readonly filteredGroups = computed<FilteredGroupView[]>(() => {
const term = this.localGroupSearchTerm().trim().toLowerCase();
const groups = this.workspaceFilteredGroups();
if (!term) {
return groups;
}
return groups.filter((group) => group.key.toLowerCase().includes(term));
});
readonly hasAnyGroups = computed(() => this.allGroups().length > 0);
readonly selectedGroup = computed(() => {
const selectedGroupKey = this.selectedGroupKey();
return (
this.filteredGroups().find(
(group) => group.key === selectedGroupKey
) ?? null
);
});
readonly selectedGroupChannels = computed<EnrichedChannel[]>(() => {
const group = this.selectedGroup();
const sortMode = this.groupChannelSortMode();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
this.progressTick();
if (!group) {
return [];
}
return sortPortalChannelItems(
group.channels,
sortMode,
(channel) => channel?.name
).map((channel) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
...channel,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
} as EnrichedChannel;
});
});
readonly activeChannelGroupKey = computed(() => {
const activeChannelUrl = this.activeChannelUrl();
if (!activeChannelUrl) {
return null;
}
const grouped = this.groupedChannels();
for (const [groupKey, channels] of Object.entries(grouped)) {
if (channels.some((channel) => channel.url === activeChannelUrl)) {
return groupKey;
}
}
return null;
});
selectGroup(groupKey: string): void {
this.selectedGroupKey.set(groupKey);
}
closeGroupSearch(): void {
this.localGroupSearchTerm.set('');
this.isGroupSearchOpen.set(false);
}
toggleGroupSearch(): void {
if (this.isGroupSearchOpen()) {
this.closeGroupSearch();
return;
}
this.isGroupSearchOpen.set(true);
}
updateGroupSearchTerm(value: string): void {
this.localGroupSearchTerm.set(value);
}
openGroupManagement(): void {
const groups = this.allGroups().map<GroupManagementDialogGroup>(
({ key, count }) => ({
key,
count,
})
);
const dialogRef = this.dialog.open(GroupManagementDialogComponent, {
data: {
groups,
hiddenGroupTitles: this.hiddenGroupTitles(),
},
width: '500px',
maxHeight: '90vh',
});
dialogRef.afterClosed().subscribe((hiddenGroupTitles) => {
if (hiddenGroupTitles === undefined) {
return;
}
this.hiddenGroupTitlesChanged.emit(hiddenGroupTitles);
});
}
setGroupChannelSortMode(mode: PortalChannelSortMode): void {
this.groupChannelSortMode.set(mode);
persistPortalChannelSortMode(GROUP_CHANNEL_SORT_STORAGE_KEY, mode);
}
onGroupsNavResizeStart(): void {
this.preservedContentWidth = this.measureContentPanelWidth();
}
onGroupsNavWidthChange(width: number): void {
this.emitSidebarWidthRequest(width, this.sidebarWidthRequested);
}
onGroupsNavResizeEnd(width: number): void {
this.emitSidebarWidthRequest(width, this.sidebarWidthRequestEnded);
this.preservedContentWidth = 0;
}
trackByChannel(_: number, channel: Channel): string {
return channel?.id;
}
trackByGroupKey(_: number, group: FilteredGroupView): string {
return group.key;
}
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggle(channel: Channel, event: MouseEvent): void {
this.favoriteToggled.emit({ channel, event });
}
onChannelContextMenu(channel: Channel, event: MouseEvent): void {
this.contextMenuChannel.set(channel);
this.contextMenuPosition.set({
x: `${event.clientX}px`,
y: `${event.clientY}px`,
});
const trigger = this.contextMenuTrigger();
if (trigger.menuOpen) {
trigger.closeMenu();
}
queueMicrotask(() => {
this.contextMenuTrigger().openMenu();
});
}
openChannelDetails(): void {
const channel = this.contextMenuChannel();
if (!channel) {
return;
}
this.contextMenuTrigger().closeMenu();
this.dialog.open(ChannelDetailsDialogComponent, {
data: channel,
maxWidth: '720px',
width: 'calc(100vw - 32px)',
});
}
/**
* Comparator for sorting groups - numeric groups first, then alphabetical
*/
readonly groupsComparator = (
a: KeyValue<string, Channel[]> | { key: string },
b: KeyValue<string, Channel[]> | { key: string }
): number => {
const numA = parseInt(a.key.replace(/\D/g, ''), 10);
const numB = parseInt(b.key.replace(/\D/g, ''), 10);
if (!Number.isNaN(numA) && !Number.isNaN(numB) && numA !== numB) {
return numA - numB;
}
if (!Number.isNaN(numA) && Number.isNaN(numB)) {
return -1;
}
if (Number.isNaN(numA) && !Number.isNaN(numB)) {
return 1;
}
return a.key.localeCompare(b.key);
};
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = Date.now();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
return 0;
}
const total = stop - start;
if (total <= 0) {
return 0;
}
const elapsed = Math.min(total, Math.max(0, now - start));
return Math.round((elapsed / total) * 100);
}
private emitSidebarWidthRequest(
navWidth: number,
emitter: OutputEmitterRef<number>
): void {
const preservedContentWidth =
this.preservedContentWidth ||
this.measureContentPanelWidth(navWidth);
const requestedWidth = Math.round(navWidth + preservedContentWidth);
if (requestedWidth > 0) {
emitter.emit(requestedWidth);
}
}
private measureContentPanelWidth(currentNavWidth?: number): number {
const contentPanel = this.hostEl.nativeElement.querySelector(
'.groups-content-panel'
);
const measuredContentWidth = this.readWidth(contentPanel);
if (measuredContentWidth > 0) {
return measuredContentWidth;
}
const hostWidth = this.readWidth(this.hostEl.nativeElement);
const totalWidth =
hostWidth > 0 ? hostWidth : Math.max(0, this.sidebarWidth() ?? 0);
const navPanel =
this.hostEl.nativeElement.querySelector('.groups-nav-panel');
const navWidth = currentNavWidth ?? this.readWidth(navPanel);
if (totalWidth > 0 && navWidth > 0) {
return Math.max(0, totalWidth - navWidth);
}
return 0;
}
private readWidth(element: Element | null): number {
if (!element) {
return 0;
}
const rectWidth = element.getBoundingClientRect().width;
if (rectWidth > 0) {
return rectWidth;
}
if (!(element instanceof HTMLElement)) {
return 0;
}
if (element.offsetWidth > 0) {
return element.offsetWidth;
}
const inlineWidth = Number.parseFloat(element.style.width);
if (Number.isFinite(inlineWidth) && inlineWidth > 0) {
return inlineWidth;
}
const computedWidth = Number.parseFloat(
window.getComputedStyle(element).width
);
if (Number.isFinite(computedWidth) && computedWidth > 0) {
return computedWidth;
}
return 0;
}
}
@@ -0,0 +1,48 @@
<div class="channel-list" id="recent-list">
@if (enrichedRecentItems().length > 0) {
@for (
item of enrichedRecentItems();
track item.channel.url;
let i = $index
) {
<app-channel-list-item
[name]="
i +
1 +
'. ' +
(item.channel?.name || 'CHANNELS.UNNAMED_CHANNEL' | translate)
"
[logo]="item.logo"
[showEpg]="shouldShowEpg()"
[isRadio]="item.channel?.radio === 'true'"
[epgProgram]="item.epgProgram"
[progressPercentage]="item.progressPercentage"
[selected]="activeChannelUrl() === item.channel?.url"
[showAuxActionButton]="true"
[auxActionIcon]="'delete'"
[auxActionTooltip]="
'WORKSPACE.DASHBOARD.REMOVE_FROM_HISTORY' | translate
"
(clicked)="onChannelClick(item.channel)"
(auxActionClicked)="onRemoveRecent(item.channel, $event)"
/>
}
} @else {
<div class="empty-recent">
<strong>
@if (searchTerm()) {
{{ 'CHANNELS.NO_SEARCH_RESULTS' | translate }}
} @else {
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }}
}
</strong>
</div>
<div class="empty-recent-hint">
@if (searchTerm()) {
{{ 'CHANNELS.TRY_DIFFERENT_SEARCH' | translate }}
} @else {
{{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }}
}
</div>
}
</div>
@@ -0,0 +1,35 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
#recent-list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
.channel-list {
overflow-x: hidden;
}
.empty-recent,
.empty-recent-hint {
padding: 16px 24px;
text-align: center;
}
.empty-recent {
margin-top: 40px;
font-size: 1.05rem;
font-weight: 520;
letter-spacing: -0.01em;
}
.empty-recent-hint {
opacity: 0.65;
font-size: 0.85rem;
}
@@ -0,0 +1,101 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from 'm3u-state';
import { Channel, EpgProgram } from 'shared-interfaces';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
export interface RecentViewItem {
channel: Channel;
viewedAt: string;
}
@Component({
selector: 'app-recent-view',
templateUrl: './recent-view.component.html',
styleUrls: ['./recent-view.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChannelListItemComponent, TranslatePipe],
})
export class RecentViewComponent {
readonly recentItems = input.required<RecentViewItem[]>();
readonly searchTerm = input('');
readonly channelEpgMap = input.required<Map<string, EpgProgram | null>>();
readonly channelIconMap = input.required<Map<string, string>>();
readonly progressTick = input.required<number>();
readonly shouldShowEpg = input.required<boolean>();
readonly activeChannelUrl = input<string | undefined>();
readonly channelSelected = output<Channel>();
readonly removeRecent = output<string>();
readonly filteredRecentItems = computed(() => {
const recentItems = this.recentItems();
const term = this.searchTerm().trim().toLowerCase();
if (!term) {
return recentItems;
}
return recentItems.filter(({ channel }) =>
`${channel.name ?? ''} ${channel.group?.title ?? ''}`
.toLowerCase()
.includes(term)
);
});
readonly enrichedRecentItems = computed(() => {
const recentItems = this.filteredRecentItems();
const epgMap = this.channelEpgMap();
const iconMap = this.channelIconMap();
this.progressTick();
return recentItems.map(({ channel, viewedAt }) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
return {
channel,
viewedAt,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
};
});
});
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = new Date().getTime();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
trackByFn(_: number, item: RecentViewItem): string {
return item.channel?.url;
}
onChannelClick(channel: Channel): void {
this.channelSelected.emit(channel);
}
onRemoveRecent(channel: Channel, event: MouseEvent): void {
event.stopPropagation();
this.removeRecent.emit(channel.url);
}
}
@@ -0,0 +1,98 @@
<div class="channel-list-loading-state" aria-live="polite" role="status">
<span class="visually-hidden">
{{ 'CHANNELS.LOADING' | translate }}
</span>
@if (isGroupsView()) {
<div class="groups-loading-layout" aria-hidden="true">
<aside class="groups-loading-nav">
<header class="groups-loading-nav__header">
<h2 class="groups-loading-nav__title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</header>
<div class="groups-loading-nav__list">
@for (row of groupRows; track row) {
<div class="groups-loading-nav__item">
<span
class="grid-skeleton-line groups-loading-nav__label"
[style.width.%]="groupLabelWidths[row]"
></span>
<span
class="grid-skeleton-line groups-loading-nav__count"
></span>
</div>
}
</div>
</aside>
<section class="groups-loading-content">
<header class="groups-loading-content__header">
<div class="groups-loading-content__meta">
<span
class="grid-skeleton-line groups-loading-content__title"
></span>
<span
class="grid-skeleton-line groups-loading-content__subtitle"
></span>
</div>
</header>
<div class="groups-loading-content__list">
@for (row of channelRows; track row) {
<div class="channel-loading-row">
<span
class="list-skeleton-avatar channel-loading-row__logo"
></span>
<span class="channel-loading-row__text">
<span
class="grid-skeleton-line channel-loading-row__title"
[style.width.%]="channelTitleWidths[row]"
></span>
<span
class="grid-skeleton-line channel-loading-row__meta"
[style.width.%]="channelMetaWidths[row]"
></span>
</span>
<span
class="list-skeleton-star channel-loading-row__favorite"
></span>
</div>
}
</div>
</section>
</div>
} @else {
<div class="channels-loading-state">
<div class="channels-loading-state__copy">
<div class="channels-loading-state__title">
{{ 'CHANNELS.LOADING' | translate }}
</div>
</div>
<div class="channels-loading-state__list" aria-hidden="true">
@for (row of channelRows; track row) {
<div class="channel-loading-row">
<span
class="list-skeleton-avatar channel-loading-row__logo"
></span>
<span class="channel-loading-row__text">
<span
class="grid-skeleton-line channel-loading-row__title"
[style.width.%]="channelTitleWidths[row]"
></span>
<span
class="grid-skeleton-line channel-loading-row__meta"
[style.width.%]="channelMetaWidths[row]"
></span>
</span>
<span
class="list-skeleton-star channel-loading-row__favorite"
></span>
</div>
}
</div>
</div>
}
</div>
@@ -0,0 +1,253 @@
@use '../../../../../portal/shared/ui/src/lib/styles/skeleton';
:host {
display: block;
height: 100%;
width: 100%;
min-height: 0;
container-type: inline-size;
}
.channel-list-loading-state {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.channels-loading-state {
display: grid;
gap: 14px;
height: 100%;
min-height: 0;
padding: 16px 12px 18px;
}
.channels-loading-state__copy {
padding: 0 4px;
}
.channels-loading-state__title,
.groups-loading-nav__title {
margin: 0;
font-size: 0.86rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
.channels-loading-state__list,
.groups-loading-content__list {
display: grid;
gap: 8px;
overflow: hidden;
}
.channel-loading-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 18px;
align-items: center;
gap: 12px;
min-height: 68px;
padding: 10px 12px;
border-radius: 14px;
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
);
}
.channel-loading-row__logo {
width: 44px;
height: 44px;
border-radius: 12px;
}
.channel-loading-row__text {
min-width: 0;
display: grid;
gap: 8px;
}
.channel-loading-row__title {
height: 11px;
border-radius: 999px;
}
.channel-loading-row__meta {
height: 9px;
border-radius: 999px;
}
.channel-loading-row__favorite {
justify-self: end;
width: 18px;
height: 18px;
border-radius: 999px;
}
.groups-loading-layout {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--app-content-bg, var(--mat-sys-surface));
}
.groups-loading-nav {
width: 208px;
min-width: 164px;
max-width: 320px;
flex-shrink: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
padding: 12px 10px 10px 12px;
box-sizing: border-box;
}
.groups-loading-nav__header {
padding: 4px 6px 12px;
flex-shrink: 0;
}
.groups-loading-nav__list {
display: grid;
gap: 8px;
min-height: 0;
overflow: hidden;
}
.groups-loading-nav__item {
display: grid;
grid-template-columns: minmax(0, 1fr) 24px;
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
);
}
.groups-loading-nav__label,
.groups-loading-nav__count,
.groups-loading-content__title,
.groups-loading-content__subtitle {
border-radius: 999px;
}
.groups-loading-nav__label {
height: 9px;
}
.groups-loading-nav__count {
width: 22px;
height: 9px;
justify-self: end;
}
.groups-loading-content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--app-content-bg, var(--mat-sys-surface));
border-top-left-radius: 16px;
}
.groups-loading-content__header {
flex-shrink: 0;
min-height: 56px;
padding: 12px 16px 10px;
border-bottom: 1px solid var(--app-separator);
}
.groups-loading-content__meta {
max-width: 180px;
display: grid;
gap: 8px;
}
.groups-loading-content__title {
width: 100%;
max-width: 156px;
height: 12px;
}
.groups-loading-content__subtitle {
width: 82px;
height: 9px;
}
.groups-loading-content__list {
flex: 1;
min-height: 0;
align-content: start;
padding: 12px 8px 12px;
}
@container (max-width: 380px) {
.channels-loading-state {
padding-inline: 8px;
}
.channel-loading-row {
grid-template-columns: 40px minmax(0, 1fr) 16px;
gap: 10px;
padding-inline: 10px;
}
.channel-loading-row__logo {
width: 40px;
height: 40px;
border-radius: 10px;
}
.groups-loading-nav {
min-width: 136px;
width: 42%;
padding-inline: 8px;
}
.groups-loading-content__header {
padding-inline: 12px;
}
.groups-loading-content__list {
padding-inline: 6px;
}
}
@@ -0,0 +1,48 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ChannelListLoadingStateComponent } from './channel-list-loading-state.component';
describe('ChannelListLoadingStateComponent', () => {
let fixture: ComponentFixture<ChannelListLoadingStateComponent>;
let component: ChannelListLoadingStateComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ChannelListLoadingStateComponent,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(ChannelListLoadingStateComponent);
component = fixture.componentInstance;
});
it('renders channel rows for non-group views', () => {
fixture.componentRef.setInput('view', 'all');
fixture.detectChanges();
expect(component.isGroupsView()).toBe(false);
expect(
fixture.nativeElement.querySelectorAll('.channel-loading-row')
.length
).toBe(component.channelRows.length);
expect(
fixture.nativeElement.querySelector('.groups-loading-layout')
).toBeNull();
});
it('renders a two-column group loading layout for the groups view', () => {
fixture.componentRef.setInput('view', 'groups');
fixture.detectChanges();
expect(component.isGroupsView()).toBe(true);
expect(
fixture.nativeElement.querySelectorAll('.groups-loading-nav__item')
.length
).toBe(component.groupRows.length);
expect(
fixture.nativeElement.querySelector('.groups-loading-content')
).not.toBeNull();
});
});
@@ -0,0 +1,25 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
@Component({
selector: 'app-channel-list-loading-state',
templateUrl: './channel-list-loading-state.component.html',
styleUrl: './channel-list-loading-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslatePipe],
})
export class ChannelListLoadingStateComponent {
readonly view = input<string>('all');
readonly isGroupsView = computed(() => this.view() === 'groups');
readonly channelRows = Array.from({ length: 9 }, (_, index) => index);
readonly groupRows = Array.from({ length: 10 }, (_, index) => index);
readonly channelMetaWidths = [38, 34, 42, 36, 44, 35, 40, 32, 39];
readonly channelTitleWidths = [72, 61, 68, 54, 78, 64, 70, 57, 75];
readonly groupLabelWidths = [78, 66, 84, 58, 73, 69, 81, 62, 76, 71];
}
@@ -0,0 +1,40 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
export interface ConfirmDialogData {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
}
@Component({
imports: [MatButtonModule, MatDialogModule, TranslateModule],
template: `
<h2 mat-dialog-title>
{{ dialogData.title }}
</h2>
<mat-dialog-content class="mat-typography">
{{ dialogData.message }}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ dialogData.cancelLabel || 'NO' | translate }}
</button>
<button mat-flat-button [mat-dialog-close]="true" color="accent">
{{ dialogData.confirmLabel || 'YES' | translate }}
</button>
</mat-dialog-actions>
`,
})
export class ConfirmDialogComponent {
readonly dialogData!: ConfirmDialogData;
readonly data = inject<ConfirmDialogData>(MAT_DIALOG_DATA);
constructor() {
this.dialogData = this.data;
}
}
@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { of } from 'rxjs';
import { DialogService } from './dialog.service';
describe('DialogService', () => {
let service: DialogService;
let dialog: { open: jest.Mock };
beforeEach(() => {
dialog = {
open: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
DialogService,
{
provide: MatDialog,
useValue: dialog,
},
],
});
service = TestBed.inject(DialogService);
});
it('opens the confirm dialog with the expected config', () => {
dialog.open.mockReturnValue({
afterClosed: () => of(false),
});
service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
onConfirm: jest.fn(),
});
expect(dialog.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
data: expect.objectContaining({
title: 'Remove',
message: 'Confirm removal?',
}),
width: '300px',
})
);
});
it('invokes onConfirm only after the dialog resolves truthy', () => {
const onConfirm = jest.fn();
dialog.open.mockReturnValue({
afterClosed: () => of(true),
});
service.openConfirmDialog({
title: 'Remove',
message: 'Confirm removal?',
onConfirm,
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,30 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
ConfirmDialogComponent,
ConfirmDialogData,
} from './confirm-dialog.component';
@Injectable({
providedIn: 'root',
})
export class DialogService {
private dialog = inject(MatDialog);
/**
* Opens the confirm dialog with provided data
* @param data dialog meta info
*/
openConfirmDialog(data: ConfirmDialogData): void {
const dialogRef = this.dialog.open<
ConfirmDialogComponent,
ConfirmDialogData
>(ConfirmDialogComponent, {
data,
width: '300px',
});
dialogRef
.afterClosed()
.subscribe((result) => (result ? data.onConfirm() : null));
}
}
@@ -0,0 +1,193 @@
<!-- Hero Section with Backdrop -->
<section class="hero">
<!-- Back Button -->
<button class="hero__back-button" (click)="onBack()">
<mat-icon>arrow_back</mat-icon>
</button>
@if (errorMessage()) {
<div class="hero__content hero__error">
<mat-icon class="error-icon">cloud_off</mat-icon>
<h2 class="error-title">{{ errorMessage() }}</h2>
<ng-content select="[hero-error-actions]"></ng-content>
</div>
} @else if (isLoading()) {
<div class="hero__content">
<!-- Skeleton Poster -->
<div class="poster">
<ngx-skeleton-loader
count="1"
[theme]="{
height: '100%',
width: '100%',
'border-radius': '8px',
'aspect-ratio': '2/3',
display: 'block',
}"
/>
</div>
<!-- Skeleton Details -->
<div class="details">
<!-- Title -->
<ngx-skeleton-loader
count="1"
[theme]="{
height: '40px',
width: '60%',
'margin-bottom': '16px',
display: 'block',
}"
/>
<!-- Tags -->
<div style="display: flex; gap: 8px; margin-bottom: 16px">
<ngx-skeleton-loader
count="3"
[theme]="{
height: '24px',
width: '60px',
display: 'block',
}"
/>
</div>
<!-- Description -->
<ngx-skeleton-loader
count="3"
[theme]="{
height: '16px',
'margin-bottom': '8px',
width: '100%',
display: 'block',
}"
/>
<!-- Meta -->
<div style="margin-top: 20px; display: flex; gap: 32px">
<ngx-skeleton-loader
count="2"
[theme]="{
height: '40px',
width: '100px',
display: 'block',
}"
/>
</div>
<!-- Buttons -->
<div style="margin-top: 24px; display: flex; gap: 12px">
<ngx-skeleton-loader
count="2"
[theme]="{
height: '48px',
width: '140px',
display: 'block',
}"
/>
</div>
</div>
</div>
} @else {
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="
!backdropUrl() && posterUrl() && !posterError()
"
[style.backgroundImage]="
backdropUrl() && !posterError()
? 'url(' + backdropUrl() + ')'
: posterUrl() && !posterError()
? 'url(' + posterUrl() + ')'
: fallbackBackdropBackground()
"
></div>
<!-- Gradient Vignette Overlay -->
<div class="hero__vignette"></div>
<!-- Hero Content -->
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl() && !posterError()) {
<img
class="poster__image"
[src]="posterUrl()"
(error)="onPosterError()"
alt="Poster"
/>
} @else {
<div
class="poster__placeholder"
[style.backgroundImage]="fallbackPosterBackground()"
>
<mat-icon class="poster__placeholder-icon"
>movie</mat-icon
>
<span class="poster__placeholder-text">{{
formattedTitle()
}}</span>
</div>
}
</div>
<div class="details">
<h1 class="details__title">{{ formattedTitle() }}</h1>
<!-- Tags Slot -->
<div class="details__tags">
<ng-content select="[hero-tags]"></ng-content>
</div>
<!-- Description -->
@if (description()) {
<p
#descriptionEl
class="details__description"
[class.details__description--expanded]="
isDescriptionExpanded()
"
>
{{ description() }}
</p>
@if (hasDescriptionOverflow() || isDescriptionExpanded()) {
<button
type="button"
class="details__description-toggle"
(click)="toggleDescription()"
[attr.aria-expanded]="isDescriptionExpanded()"
>
<span>{{
(isDescriptionExpanded()
? 'SHOW_LESS'
: 'SHOW_MORE'
) | translate
}}</span>
<mat-icon aria-hidden="true">
{{
isDescriptionExpanded()
? 'expand_less'
: 'expand_more'
}}
</mat-icon>
</button>
}
}
<!-- Metadata Grid Slot -->
<div class="details__meta">
<ng-content select="[hero-meta]"></ng-content>
</div>
<!-- Action Buttons Slot -->
<div class="action-buttons">
<ng-content select="[hero-actions]"></ng-content>
</div>
<!-- Additional Content (Seasons, Trailers, etc.) -->
<ng-content></ng-content>
</div>
</div>
}
</section>
@@ -0,0 +1,606 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
width: 100%;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--tag-bg: rgba(255, 255, 255, 0.1);
--tag-border: rgba(255, 255, 255, 0.2);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
// ============================================================================
// Hero Section - Full container with backdrop
// ============================================================================
.hero {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(
--surface-bg
); // Solid background prevents any bleed-through
// Floating back button over backdrop
&__back-button {
position: absolute;
top: 16px;
left: 16px;
z-index: 10;
app-region: no-drag;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
border: none;
cursor: pointer;
transition: all 0.2s ease;
color: #fff;
&:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Backdrop image layer - fixed at top, never repeats
&__backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: auto; // Only use height, not bottom
height: 60%;
background-size: cover;
background-position: center 20%;
background-repeat: no-repeat;
z-index: 0;
overflow: hidden;
// Fallback blur effect when using poster as backdrop
&--blurred {
filter: blur(40px) saturate(1.3);
transform: scale(1.4);
height: 65%;
}
}
// Gradient overlays for text legibility
&__vignette {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0; // Full height to cover everything
z-index: 1;
pointer-events: none;
background: linear-gradient(
to bottom,
transparent 0%,
transparent 35%,
var(--surface-bg) 55%,
var(--surface-bg) 100%
);
// Top area gradient for text legibility over backdrop
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60%;
background: linear-gradient(
to top,
var(--hero-gradient-end) 0%,
var(--hero-gradient-mid) 50%,
var(--hero-gradient-start) 100%
);
}
// Side fade for poster area
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
height: 50%;
width: 40%;
background: linear-gradient(
to right,
var(--hero-gradient-mid) 0%,
var(--hero-gradient-start) 100%
);
}
}
// Scrollable content area
&__content {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
gap: 32px;
align-items: flex-start;
padding: 140px 40px 60px;
max-width: var(--content-max-width);
margin: 0 auto;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
// Hide scrollbar but keep functionality
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
// Error state
&__error {
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 16px;
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--text-muted);
}
.error-title {
font-size: 1.25rem;
font-weight: 500;
color: var(--text-secondary);
margin: 0;
}
}
}
// ============================================================================
// Poster Card - Floating with shadow
// ============================================================================
.poster {
flex-shrink: 0;
width: var(--poster-width);
align-self: flex-start;
&:hover .poster__image {
transform: scale(1.02);
}
&__image {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
box-shadow: var(--poster-shadow);
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
transition: transform 0.3s ease;
}
&__placeholder {
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 8px;
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.8);
box-shadow: var(--poster-shadow);
overflow: hidden;
&-icon {
font-size: 48px;
width: 48px;
height: 48px;
opacity: 0.5;
margin-bottom: 12px;
}
&-text {
font-size: 1rem;
font-weight: 500;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
padding: 16px;
}
}
}
// ============================================================================
// Details Section - Title, metadata, description
// ============================================================================
.details {
flex: 1;
min-width: 0;
padding-bottom: 24px;
&__title {
font-size: clamp(1.75rem, 4vw, 3rem);
font-weight: 700;
line-height: 1.1;
margin: 0 0 16px 0;
letter-spacing: -0.02em;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
// These styles are for Projected Content, we need to ensure they apply
// using ::ng-deep or just assume the global styles will handle them?
// Actually, since we project content, the styles defined here might NOT apply to the projected content
// unless we use ::ng-deep or the content itself has these styles.
// However, since we are moving the "shell", the container classes (.details__tags) are here.
// The inner items (.details__tag) will be in the projected content.
// So we need ::ng-deep here for .details__tag.
::ng-deep .details__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__description {
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 6px;
max-width: 600px;
// Truncate long descriptions
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
&--expanded {
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
}
&__description-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 0 20px 0;
padding: 4px 0;
background: transparent;
border: 0;
cursor: pointer;
color: var(--text-secondary);
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
transition:
color 180ms ease,
transform 180ms ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
transition: transform 180ms ease;
}
&:hover {
color: var(--text-primary);
mat-icon {
transform: translateY(2px);
}
}
&[aria-expanded='true']:hover mat-icon {
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: 2px;
}
}
&__meta {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
margin-bottom: 24px;
}
// Similar issue for meta items
::ng-deep .details__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 0;
// Use ::ng-deep to style the projected buttons
::ng-deep .play-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
font-size: 1rem;
font-weight: 600;
background: #fff;
color: #141414;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.85);
transform: scale(1.02);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
::ng-deep .favorite-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.hero {
&__backdrop {
height: 45%;
&--blurred {
height: 50%;
}
}
&__vignette {
background: linear-gradient(
to bottom,
transparent 0%,
transparent 25%,
var(--surface-bg) 45%,
var(--surface-bg) 100%
);
&::before {
height: 50%;
}
&::after {
display: none;
}
}
&__content {
padding: 60px 20px 40px;
gap: 16px;
}
}
.poster {
// Mobile poster adjustments - none needed
}
.details {
&__title {
font-size: 1.5rem;
}
&__description {
-webkit-line-clamp: 3;
&--expanded {
-webkit-line-clamp: unset;
}
}
&__meta {
gap: 12px 20px;
}
}
.action-buttons {
width: 100%;
::ng-deep .play-btn,
::ng-deep .favorite-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
}
}
// ============================================================================
// Animations
// ============================================================================
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hero__content {
animation: fadeInUp 0.6s ease-out 0.2s both;
}
// Skeleton Loader Overrides
ngx-skeleton-loader {
display: block;
width: 100%;
// Ensure the loader inside poster takes up the correct aspect ratio space
::ng-deep .loader {
display: block;
height: 100%;
}
}
:host-context(.dark-theme) {
ngx-skeleton-loader {
::ng-deep .loader {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
.poster {
// ... existing styles ...
// Ensure poster container has height/ratio for skeleton
min-height: 330px; // Fallback
aspect-ratio: 2 / 3;
}
@@ -0,0 +1,41 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ContentHeroComponent } from './content-hero.component';
describe('ContentHeroComponent', () => {
let fixture: ComponentFixture<ContentHeroComponent>;
const originalResizeObserver = globalThis.ResizeObserver;
afterEach(() => {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: originalResizeObserver,
});
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContentHeroComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(ContentHeroComponent);
});
it('renders description content when ResizeObserver is unavailable', () => {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: undefined,
});
fixture.componentRef.setInput('title', 'Fallback_Title');
fixture.componentRef.setInput('description', 'Plain description');
expect(() => fixture.detectChanges()).not.toThrow();
const host = fixture.nativeElement as HTMLElement;
expect(host.textContent).toContain('Fallback Title');
expect(host.textContent).toContain('Plain description');
});
});
@@ -0,0 +1,120 @@
import {
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal,
viewChild,
ElementRef,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
@Component({
selector: 'app-content-hero',
standalone: true,
imports: [
MatIconModule,
MatButtonModule,
NgxSkeletonLoaderComponent,
TranslateModule,
],
templateUrl: './content-hero.component.html',
styleUrls: ['./content-hero.component.scss'],
})
export class ContentHeroComponent {
private readonly destroyRef = inject(DestroyRef);
readonly title = input<string>();
readonly description = input<string>();
readonly posterUrl = input<string>();
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backClicked = output<void>();
readonly posterError = signal(false);
readonly descriptionEl = viewChild<ElementRef<HTMLElement>>('descriptionEl');
readonly isDescriptionExpanded = signal(false);
readonly hasDescriptionOverflow = signal(false);
private resizeObserver?: ResizeObserver;
constructor() {
effect(() => {
// Re-measure whenever description content or the element changes.
this.description();
const el = this.descriptionEl()?.nativeElement;
if (!el) return;
this.measureOverflow(el);
this.observeOverflow(el);
});
this.destroyRef.onDestroy(() => this.resizeObserver?.disconnect());
}
onPosterError(): void {
this.posterError.set(true);
}
readonly formattedTitle = computed(() => {
const t = this.title();
if (!t) return '';
// Replace underscores with spaces for cleaner UX on slug/filename style titles
return t.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
});
private calculateHue(text: string): number {
if (!text) return 0;
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
}
readonly fallbackPosterBackground = computed(() => {
const hue = this.calculateHue(this.title() || 'placeholder');
const h2 = (hue + 40) % 360;
return `linear-gradient(135deg, hsl(${hue}, 40%, 25%) 0%, hsl(${h2}, 50%, 15%) 100%)`;
});
readonly fallbackBackdropBackground = computed(() => {
const hue = this.calculateHue(this.title() || 'placeholder');
const h2 = (hue + 60) % 360;
return `linear-gradient(135deg, hsl(${hue}, 50%, 15%) 0%, hsl(${h2}, 80%, 5%) 100%)`;
});
onBack(): void {
this.backClicked.emit();
}
toggleDescription(): void {
this.isDescriptionExpanded.update((v) => !v);
}
private measureOverflow(el: HTMLElement): void {
// Measure only in the clamped state; if already expanded, clamped overflow
// is implied when the element previously overflowed.
if (this.isDescriptionExpanded()) return;
this.hasDescriptionOverflow.set(el.scrollHeight > el.clientHeight + 1);
}
private observeOverflow(el: HTMLElement): void {
this.resizeObserver?.disconnect();
if (typeof ResizeObserver === 'undefined') {
this.measureOverflow(el);
return;
}
this.resizeObserver = new ResizeObserver(() => this.measureOverflow(el));
this.resizeObserver.observe(el);
}
}
@@ -0,0 +1,71 @@
<section
class="external-playback-dock"
[class.external-playback-dock--compact]="compact()"
[class.external-playback-dock--error]="session().status === 'error'"
>
<button
type="button"
class="external-playback-dock__artwork"
[class.external-playback-dock__artwork--interactive]="
artworkInteractive()
"
[disabled]="!artworkInteractive()"
[attr.aria-label]="
artworkInteractive() ? 'Open in playlist' : null
"
(click)="onArtworkClick()"
>
@if (showArtwork()) {
<img
[src]="artworkUrl()"
[alt]="session().title"
loading="lazy"
(error)="onArtworkError()"
/>
} @else {
<div class="external-playback-dock__placeholder">
<mat-icon>{{ artworkPlaceholderIcon() }}</mat-icon>
</div>
}
</button>
<div class="external-playback-dock__meta">
<div class="external-playback-dock__title">{{ session().title }}</div>
<div class="external-playback-dock__status">
<span class="external-playback-dock__chip">{{ playerLabel() }}</span>
<span
class="external-playback-dock__status-separator"
aria-hidden="true"
>·</span
>
@if (showSpinner()) {
<mat-spinner diameter="12" strokeWidth="2"></mat-spinner>
} @else if (showStatusIcon()) {
<mat-icon class="external-playback-dock__status-icon">{{
statusIcon()
}}</mat-icon>
}
<span class="external-playback-dock__status-text">{{
statusLabel()
}}</span>
</div>
</div>
<div class="external-playback-dock__actions">
@if (showCloseButton()) {
<button
type="button"
mat-button
class="external-playback-dock__button"
(click)="closeClicked.emit()"
>
<mat-icon>stop_circle</mat-icon>
@if (!compact()) {
<span>{{
'WORKSPACE.SHELL.EXTERNAL_PLAYBACK_CLOSE' | translate
}}</span>
}
</button>
}
</div>
</section>
@@ -0,0 +1,192 @@
:host {
display: block;
}
.external-playback-dock {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-height: 72px;
padding: 2px 14px;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
border-radius: 15px 15px 0 0;
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-surface-container-high) 88%, white 12%),
color-mix(in srgb, var(--mat-sys-surface-container) 92%, transparent)
);
box-shadow:
0 18px 42px rgba(15, 23, 42, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
.external-playback-dock--error {
border-color: color-mix(in srgb, var(--mat-sys-error) 50%, transparent);
background:
linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-error-container) 72%, transparent),
color-mix(in srgb, var(--mat-sys-surface-container) 90%, transparent)
);
}
.external-playback-dock--compact {
min-height: 48px;
padding: 8px 10px;
gap: 10px;
border-radius: 16px;
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
.external-playback-dock__artwork {
width: 38px;
height: 38px;
border-radius: 12px;
}
.external-playback-dock__title {
font-size: 0.86rem;
}
.external-playback-dock__status {
font-size: 0.72rem;
}
}
.external-playback-dock__artwork {
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 14px;
overflow: hidden;
background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 92%, transparent);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
color: inherit;
cursor: default;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
img,
.external-playback-dock__placeholder {
width: 100%;
height: 100%;
}
img {
object-fit: cover;
display: block;
}
&--interactive {
cursor: pointer;
&:hover {
transform: scale(1.04);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.32),
0 6px 16px rgba(0, 0, 0, 0.28);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: 2px;
}
}
}
.external-playback-dock__placeholder {
display: grid;
place-items: center;
color: var(--mat-sys-on-surface-variant);
}
.external-playback-dock__meta {
min-width: 0;
}
.external-playback-dock__chip {
display: inline-flex;
align-items: center;
height: 18px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--mat-sys-surface-container-highest) 80%,
transparent
);
color: var(--mat-sys-on-surface-variant);
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 70%, transparent);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.external-playback-dock__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
font-weight: 700;
color: var(--mat-sys-on-surface);
margin-bottom: 2px;
}
.external-playback-dock__status {
display: flex;
align-items: center;
gap: 6px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.78rem;
min-width: 0;
.external-playback-dock__status-separator {
opacity: 0.55;
font-weight: 700;
line-height: 1;
}
.external-playback-dock__status-icon {
width: 14px;
height: 14px;
font-size: 14px;
}
.external-playback-dock__status-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
mat-spinner {
flex-shrink: 0;
}
}
.external-playback-dock__actions {
display: flex;
align-items: center;
gap: 6px;
}
.external-playback-dock__button {
border-radius: 999px;
white-space: nowrap;
}
@media (max-width: 720px) {
.external-playback-dock {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.external-playback-dock:not(.external-playback-dock--compact) {
.external-playback-dock__button span {
display: none;
}
}
}
@@ -0,0 +1,149 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
TranslateLoader,
TranslateModule,
TranslateService,
} from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { ExternalPlayerSession } from 'shared-interfaces';
import { ExternalPlaybackDockComponent } from './external-playback-dock.component';
class FakeTranslateLoader implements TranslateLoader {
getTranslation(): Observable<Record<string, unknown>> {
return of({
WORKSPACE: {
SHELL: {
EXTERNAL_PLAYBACK_CLOSE: 'Close player',
},
},
});
}
}
describe('ExternalPlaybackDockComponent', () => {
let fixture: ComponentFixture<ExternalPlaybackDockComponent>;
let component: ExternalPlaybackDockComponent;
const session: ExternalPlayerSession = {
id: 'session-1',
player: 'mpv',
status: 'launching',
title: 'Example Video',
streamUrl: 'https://example.com/video.mp4',
startedAt: '2026-03-07T10:00:00.000Z',
updatedAt: '2026-03-07T10:00:00.000Z',
canClose: true,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ExternalPlaybackDockComponent,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: FakeTranslateLoader,
},
}),
],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setDefaultLang('en');
translate.use('en');
fixture = TestBed.createComponent(ExternalPlaybackDockComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('session', session);
fixture.detectChanges();
});
it('renders launch state copy for the active session', () => {
const text = fixture.nativeElement.textContent;
expect(text).toContain('Example Video');
expect(text).toContain('MPV');
expect(text).toContain('Launching');
expect(text).toContain('Close player');
});
it('emits a single close action when the close button is clicked', () => {
const closeSpy = jest.fn();
component.closeClicked.subscribe(closeSpy);
const closeButton = fixture.debugElement.query(
By.css('.external-playback-dock__button')
);
closeButton.nativeElement.click();
expect(closeSpy).toHaveBeenCalledTimes(1);
});
it('renders both artwork and close as buttons', () => {
const buttons = fixture.debugElement.queryAll(By.css('button'));
expect(buttons).toHaveLength(2);
expect(
buttons[0].nativeElement.classList.contains(
'external-playback-dock__artwork'
)
).toBe(true);
expect(
buttons[1].nativeElement.classList.contains(
'external-playback-dock__button'
)
).toBe(true);
});
it('disables the artwork button when the session has no playlist target', () => {
const artwork = fixture.debugElement.query(
By.css('.external-playback-dock__artwork')
);
expect(artwork.nativeElement.disabled).toBe(true);
});
it('enables the artwork and emits when the session has a playlist target', () => {
fixture.componentRef.setInput('session', {
...session,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'vod',
},
});
fixture.detectChanges();
const artworkSpy = jest.fn();
component.artworkClicked.subscribe(artworkSpy);
const artwork = fixture.debugElement.query(
By.css('.external-playback-dock__artwork')
);
expect(artwork.nativeElement.disabled).toBe(false);
artwork.nativeElement.click();
expect(artworkSpy).toHaveBeenCalledTimes(1);
});
it('falls back to a placeholder icon when artwork fails to load', () => {
fixture.componentRef.setInput('session', {
...session,
thumbnail: 'https://example.com/broken.png',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'vod',
},
});
fixture.detectChanges();
const image = fixture.debugElement.query(By.css('img'));
image.triggerEventHandler('error');
fixture.detectChanges();
expect(fixture.debugElement.query(By.css('img'))).toBeNull();
expect(
fixture.debugElement
.query(By.css('.external-playback-dock__placeholder mat-icon'))
.nativeElement.textContent.trim()
).toBe('movie');
});
});
@@ -0,0 +1,105 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
input,
output,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ExternalPlayerSession } from 'shared-interfaces';
@Component({
selector: 'app-external-playback-dock',
imports: [
MatButtonModule,
MatIcon,
MatProgressSpinnerModule,
TranslatePipe,
],
templateUrl: './external-playback-dock.component.html',
styleUrl: './external-playback-dock.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ExternalPlaybackDockComponent {
readonly session = input.required<ExternalPlayerSession>();
readonly compact = input(false);
readonly closeClicked = output<void>();
readonly artworkClicked = output<void>();
private readonly artworkFailed = signal(false);
readonly playerLabel = computed(() => this.session().player.toUpperCase());
readonly artworkUrl = computed(
() => this.session().thumbnail?.trim() ?? ''
);
readonly statusLabel = computed(() => {
const session = this.session();
switch (session.status) {
case 'launching':
return 'Launching…';
case 'opened':
case 'playing':
return 'Opened';
case 'error':
return session.error || 'Playback failed';
default:
return 'Closed';
}
});
readonly statusIcon = computed(() => {
const status = this.session().status;
if (status === 'error') return 'error_outline';
return 'open_in_new';
});
readonly showSpinner = computed(
() => this.session().status === 'launching'
);
readonly showStatusIcon = computed(
() => this.session().status !== 'launching'
);
readonly showArtwork = computed(
() => !!this.artworkUrl() && !this.artworkFailed()
);
readonly artworkPlaceholderIcon = computed(() => {
const contentType = this.session().contentInfo?.contentType;
switch (contentType) {
case 'vod':
return 'movie';
case 'episode':
return 'video_library';
default:
return 'live_tv';
}
});
readonly artworkInteractive = computed(
() => !!this.session().contentInfo?.playlistId
);
readonly showCloseButton = computed(
() => this.session().canClose && this.session().status !== 'error'
);
constructor() {
effect(() => {
this.artworkUrl();
this.artworkFailed.set(false);
});
}
onArtworkError(): void {
this.artworkFailed.set(true);
}
onArtworkClick(): void {
if (!this.artworkInteractive()) return;
this.artworkClicked.emit();
}
}
@@ -0,0 +1,55 @@
import { Component, computed, input } from '@angular/core';
@Component({
selector: 'app-progress-capsule',
standalone: true,
template: `
<div
class="progress-capsule"
[class.progress-capsule--watched]="isWatched()"
>
<div
class="progress-capsule__fill"
[style.width.%]="progress()"
></div>
</div>
`,
styles: [
`
.progress-capsule {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 5px;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
&__fill {
height: 100%;
background: linear-gradient(
90deg,
#e50914 0%,
#ff4d4d 100%
);
transition: width 0.3s ease-out;
border-radius: 0 2px 2px 0;
}
&--watched {
.progress-capsule__fill {
background: linear-gradient(
90deg,
#46d369 0%,
#2ecc71 100%
);
}
}
}
`,
],
})
export class ProgressCapsuleComponent {
readonly progress = input.required<number>();
readonly isWatched = computed(() => this.progress() >= 90);
}
@@ -0,0 +1,341 @@
import {
Directive,
ElementRef,
Renderer2,
inject,
input,
output,
OnInit,
OnDestroy,
effect,
signal,
AfterViewInit,
} from '@angular/core';
const SHARED_SIDEBAR_WIDTH_KEY = 'sidebar-width';
const SIDEBAR_WIDTH_KEY_ALIASES = new Set([
SHARED_SIDEBAR_WIDTH_KEY,
'downloads-sidebar-width',
'workspace-sources-panel-width',
'workspace-context-panel-width',
'workspace-settings-panel-width',
'workspace-favorites-panel-width',
'iptvnator_video_sidebar_width',
]);
/**
* ResizableDirective - Makes an element horizontally resizable by dragging its edge.
*
* Usage:
* ```html
* <div appResizable [minWidth]="200" [maxWidth]="600" [storageKey]="'sidebar-width'">
* Content here
* </div>
* ```
*
* Features:
* - Smooth drag handle with visual feedback
* - Persists width to localStorage
* - Respects min/max width constraints
* - Emits width changes for reactive updates
*/
@Directive({
selector: '[appResizable]',
standalone: true,
})
export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
/** Minimum width in pixels */
readonly minWidth = input<number>(200);
/** Maximum width in pixels */
readonly maxWidth = input<number>(600);
/** LocalStorage key for persisting width. Defaults to shared sidebar key when omitted. */
readonly storageKey = input<string>('');
/** Default width to use when no stored value exists */
readonly defaultWidth = input<number>(400);
/** Position of the resize handle: 'right' or 'left' */
readonly handlePosition = input<'right' | 'left'>('right');
/** Emits when width changes during resize */
readonly widthChange = output<number>();
/** Emits when resize starts */
readonly resizeStart = output<void>();
/** Emits when resize ends */
readonly resizeEnd = output<number>();
private handleElement: HTMLElement | null = null;
private isResizing = signal(false);
private startX = 0;
private startWidth = 0;
private currentWidth = signal(0);
// Event listener references for cleanup
private boundMouseMove: ((e: MouseEvent) => void) | null = null;
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
private readonly el = inject(ElementRef<HTMLElement>);
private readonly renderer = inject(Renderer2);
constructor() {
// Apply resizing class when dragging
effect(() => {
if (this.isResizing()) {
this.renderer.addClass(document.body, 'resizing-active');
this.renderer.addClass(this.el.nativeElement, 'is-resizing');
} else {
this.renderer.removeClass(document.body, 'resizing-active');
this.renderer.removeClass(this.el.nativeElement, 'is-resizing');
}
});
}
ngOnInit(): void {
// Load persisted width FIRST to avoid flash of default width
this.loadPersistedWidth();
this.setupStyles();
}
ngAfterViewInit(): void {
this.createResizeHandle();
}
ngOnDestroy(): void {
this.removeGlobalListeners();
if (this.handleElement) {
this.handleElement.remove();
}
}
private setupStyles(): void {
const el = this.el.nativeElement;
// Ensure the element has position relative for handle positioning
const computedStyle = window.getComputedStyle(el);
if (computedStyle.position === 'static') {
this.renderer.setStyle(el, 'position', 'relative');
}
// Add transition for smooth width changes (only when not dragging)
this.renderer.addClass(el, 'resizable-element');
}
private createResizeHandle(): void {
const handle = this.renderer.createElement('div');
this.handleElement = handle;
// Set up handle classes based on position
this.renderer.addClass(handle, 'resize-handle');
this.renderer.addClass(
handle,
`resize-handle--${this.handlePosition()}`
);
// Create inner visual elements for the handle
const handleLine = this.renderer.createElement('div');
this.renderer.addClass(handleLine, 'resize-handle__line');
this.renderer.appendChild(handle, handleLine);
// Create grip dots
const gripDots = this.renderer.createElement('div');
this.renderer.addClass(gripDots, 'resize-handle__grip');
for (let i = 0; i < 3; i++) {
const dot = this.renderer.createElement('span');
this.renderer.appendChild(gripDots, dot);
}
this.renderer.appendChild(handle, gripDots);
// Mouse events
this.renderer.listen(handle, 'mousedown', (e: MouseEvent) =>
this.onMouseDown(e)
);
// Touch events for mobile support
this.renderer.listen(handle, 'touchstart', (e: TouchEvent) =>
this.onTouchStart(e)
);
// Append handle to element
this.renderer.appendChild(this.el.nativeElement, handle);
}
private loadPersistedWidth(): void {
const { key, legacyKeys } = this.resolveStorageKey();
let widthToApply = this.defaultWidth();
if (key) {
let stored = localStorage.getItem(key);
let sourceLegacyKey: string | null = null;
if (!stored) {
for (const legacyKey of legacyKeys) {
const legacyValue = localStorage.getItem(legacyKey);
if (legacyValue) {
stored = legacyValue;
sourceLegacyKey = legacyKey;
break;
}
}
}
if (stored) {
const width = parseInt(stored, 10);
if (!isNaN(width)) {
widthToApply = this.clampWidth(width);
localStorage.setItem(key, widthToApply.toString());
if (sourceLegacyKey && sourceLegacyKey !== key) {
localStorage.removeItem(sourceLegacyKey);
}
}
}
}
// Apply width immediately without transition to avoid flash
this.setWidthImmediate(widthToApply);
}
/** Set width immediately without CSS transition */
private setWidthImmediate(width: number): void {
this.currentWidth.set(width);
// Temporarily disable transition for immediate application
this.renderer.setStyle(this.el.nativeElement, 'transition', 'none');
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
// Re-enable transition after a frame
requestAnimationFrame(() => {
this.renderer.removeStyle(this.el.nativeElement, 'transition');
});
}
private onMouseDown(e: MouseEvent): void {
e.preventDefault();
e.stopPropagation();
this.startResize(e.clientX);
this.boundMouseMove = (event: MouseEvent) => this.onMouseMove(event);
this.boundMouseUp = () => this.onMouseUp();
document.addEventListener('mousemove', this.boundMouseMove);
document.addEventListener('mouseup', this.boundMouseUp);
}
private onTouchStart(e: TouchEvent): void {
if (e.touches.length !== 1) return;
e.preventDefault();
e.stopPropagation();
this.startResize(e.touches[0].clientX);
this.boundTouchMove = (event: TouchEvent) => this.onTouchMove(event);
this.boundTouchEnd = () => this.onTouchEnd();
document.addEventListener('touchmove', this.boundTouchMove, {
passive: false,
});
document.addEventListener('touchend', this.boundTouchEnd);
}
private startResize(clientX: number): void {
this.isResizing.set(true);
this.startX = clientX;
this.startWidth = this.el.nativeElement.offsetWidth;
this.resizeStart.emit();
}
private onMouseMove(e: MouseEvent): void {
if (!this.isResizing()) return;
this.resize(e.clientX);
}
private onTouchMove(e: TouchEvent): void {
if (!this.isResizing() || e.touches.length !== 1) return;
e.preventDefault();
this.resize(e.touches[0].clientX);
}
private resize(clientX: number): void {
const delta =
this.handlePosition() === 'right'
? clientX - this.startX
: this.startX - clientX;
const newWidth = this.clampWidth(this.startWidth + delta);
this.setWidth(newWidth);
this.widthChange.emit(newWidth);
}
private onMouseUp(): void {
this.endResize();
}
private onTouchEnd(): void {
this.endResize();
}
private endResize(): void {
if (!this.isResizing()) return;
this.isResizing.set(false);
this.removeGlobalListeners();
this.persistWidth();
this.resizeEnd.emit(this.currentWidth());
}
private removeGlobalListeners(): void {
if (this.boundMouseMove) {
document.removeEventListener('mousemove', this.boundMouseMove);
this.boundMouseMove = null;
}
if (this.boundMouseUp) {
document.removeEventListener('mouseup', this.boundMouseUp);
this.boundMouseUp = null;
}
if (this.boundTouchMove) {
document.removeEventListener('touchmove', this.boundTouchMove);
this.boundTouchMove = null;
}
if (this.boundTouchEnd) {
document.removeEventListener('touchend', this.boundTouchEnd);
this.boundTouchEnd = null;
}
}
private clampWidth(width: number): number {
return Math.max(this.minWidth(), Math.min(this.maxWidth(), width));
}
private setWidth(width: number): void {
this.currentWidth.set(width);
this.renderer.setStyle(this.el.nativeElement, 'width', `${width}px`);
}
private persistWidth(): void {
const { key } = this.resolveStorageKey();
if (key) {
localStorage.setItem(key, this.currentWidth().toString());
}
}
private resolveStorageKey(): { key: string; legacyKeys: string[] } {
const rawKey = this.storageKey().trim();
if (!rawKey) {
return { key: SHARED_SIDEBAR_WIDTH_KEY, legacyKeys: [] };
}
if (SIDEBAR_WIDTH_KEY_ALIASES.has(rawKey)) {
return {
key: SHARED_SIDEBAR_WIDTH_KEY,
legacyKeys: rawKey === SHARED_SIDEBAR_WIDTH_KEY ? [] : [rawKey],
};
}
return { key: rawKey, legacyKeys: [] };
}
}
@@ -0,0 +1,179 @@
/**
* Resizable Directive Styles
*
* A refined, subtle resize handle that provides clear affordance
* without being visually intrusive. Uses a vertical grip pattern
* that becomes more prominent on hover/active states.
*/
// Prevent text selection during resize
body.resizing-active {
user-select: none !important;
cursor: col-resize !important;
* {
cursor: col-resize !important;
}
}
// Base element styles
.resizable-element {
// Smooth transition when not actively resizing
&:not(.is-resizing) {
transition: width 0.15s ease-out;
}
&.is-resizing {
transition: none;
}
}
// Resize handle base styles
.resize-handle {
position: absolute;
top: 0;
bottom: 0;
width: 12px;
z-index: 100;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
// Visual feedback on hover/active
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 4px;
background: transparent;
transition: background-color 0.2s ease;
border-radius: 2px;
}
&:hover::before,
&:active::before {
background: rgba(255, 255, 255, 0.08);
}
// Position variants
&--right {
right: -6px;
&::before {
left: 50%;
transform: translateX(-50%);
}
}
&--left {
left: -6px;
&::before {
right: 50%;
transform: translateX(50%);
}
}
// The main visible line
&__line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
transition: all 0.2s ease;
}
&:hover &__line {
width: 2px;
background: rgba(255, 255, 255, 0.25);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
}
&:active &__line {
width: 2px;
background: var(--mdc-theme-primary, #6366f1);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
}
// Grip dots indicator
&__grip {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
span {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transition: all 0.15s ease;
}
}
&:hover &__grip {
opacity: 1;
span {
background: rgba(255, 255, 255, 0.6);
}
}
&:active &__grip {
opacity: 1;
span {
background: var(--mdc-theme-primary, #6366f1);
transform: scale(1.2);
}
}
}
// Light theme adjustments (if needed)
.light-theme {
.resize-handle {
&::before {
background: transparent;
}
&:hover::before,
&:active::before {
background: rgba(0, 0, 0, 0.04);
}
&:active::before {
background: rgba(0, 0, 0, 0.08);
}
&__line {
background: rgba(0, 0, 0, 0.12);
}
&:hover &__line {
background: rgba(0, 0, 0, 0.25);
box-shadow: 0 0 8px rgba(0, 0, 0, 0.05);
}
&:active &__line {
background: var(--mdc-theme-primary, #6366f1);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.2);
}
&__grip {
span {
background: rgba(0, 0, 0, 0.3);
}
}
&:hover &__grip {
span {
background: rgba(0, 0, 0, 0.5);
}
}
}
}
@@ -0,0 +1,344 @@
<section class="seasons-section">
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">Seasons and Episodes</h3>
@if (selectedSeason) {
<button class="back-btn" (click)="selectedSeason = undefined">
<mat-icon>arrow_back</mat-icon>
<span>Back to seasons</span>
</button>
}
</div>
@if (selectedSeason && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle value="grid" [matTooltip]="'Grid view'">
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="list" [matTooltip]="'List view'">
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</div>
}
</div>
<div class="content-container">
@if (isLoading()) {
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else if (showSeriesEmptyState()) {
<div class="empty-state-panel" role="status">
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">No episodes available</h4>
<p class="empty-state-panel__message">
This series is listed by your provider, but no seasons or
episodes are available to play.
</p>
</div>
} @else if (!selectedSeason) {
<!-- Seasons Grid -->
<div class="seasons-grid">
@for (
season of seasons() | keyvalue: compareSeasons;
track $index
) {
<button
class="season-card"
(click)="selectSeason(season.key)"
[class.season-card--has-episodes]="season.value?.length"
>
<div class="season-card__number">{{ season.key }}</div>
<div class="season-card__label">Season</div>
@if (season.value?.length) {
<div class="season-card__episodes">
{{ season.value.length }}
{{
season.value.length === 1
? 'Episode'
: 'Episodes'
}}
</div>
}
<div class="season-card__arrow">
<mat-icon>chevron_right</mat-icon>
</div>
</button>
}
</div>
} @else if (showSeasonEmptyState()) {
<div class="empty-state-panel empty-state-panel--season" role="status">
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">This season is empty</h4>
<p class="empty-state-panel__message">
This season is listed by your provider, but no playable
episodes are available.
</p>
</div>
} @else {
@if (viewMode() === 'grid') {
<!-- Episodes Grid View -->
<div class="episodes-grid">
@for (episode of seasons()[selectedSeason]; track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-card"
(click)="selectEpisode(episode)"
[class.episode-card--watched]="
isEpisodeWatched(episode)
"
[class.episode-card--in-progress]="
isEpisodeInProgress(episode)
"
[class.episode-card--launching]="
isEpisodeLaunching(episode)
"
[class.episode-card--active-external]="
isEpisodeActiveExternal(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
<img
[src]="episodeInfo.movie_image"
(error)="
$event.target.src =
'./assets/images/default-episode.png'
"
alt="Episode thumbnail"
loading="lazy"
/>
} @else {
<div class="episode-card__placeholder">
<mat-icon>play_circle_outline</mat-icon>
</div>
}
@if (getEpisodeProgress(episode) > 0) {
<app-progress-capsule
[progress]="getEpisodeProgress(episode)"
/>
@if (getEpisodePositionText(episode); as time) {
<div class="episode-card__position-text">
{{ time }}
</div>
}
}
<app-watched-badge
[isWatched]="isEpisodeWatched(episode)"
/>
<div class="episode-card__actions">
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="download-btn download-btn--completed"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
>
<mat-icon>play_circle</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="download-btn download-btn--downloading"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="download-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
>
<mat-icon>download</mat-icon>
</button>
}
}
<button
mat-icon-button
class="toggle-watched-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
<div class="episode-card__overlay">
<div class="episode-card__play">
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="26"></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</div>
</div>
<div class="episode-card__number">
{{ episode.episode_num }}
</div>
</div>
<div class="episode-card__info">
<h4 class="episode-card__title">
{{ episode.episode_num }}. {{ episode.title }}
</h4>
@if (episodeInfo?.plot) {
<p class="episode-card__plot">
{{ episodeInfo.plot }}
</p>
}
@if (episodeInfo?.duration) {
<span class="episode-card__duration">
{{ episodeInfo.duration }}
</span>
}
</div>
</article>
}
</div>
} @else {
<!-- Episodes List View -->
<div class="episodes-list">
@for (episode of seasons()[selectedSeason]; track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-list-item"
(click)="selectEpisode(episode)"
[class.episode-list-item--watched]="isEpisodeWatched(episode)"
[class.episode-list-item--in-progress]="isEpisodeInProgress(episode)"
[class.episode-list-item--launching]="isEpisodeLaunching(episode)"
[class.episode-list-item--active-external]="isEpisodeActiveExternal(episode)"
>
<div class="episode-list-item__number">
{{ episode.episode_num }}
</div>
<div class="episode-list-item__content">
<div class="episode-list-item__header">
<h4 class="episode-list-item__title">
{{ episode.title }}
@if (isEpisodeWatched(episode)) {
<mat-icon class="watched-icon">check_circle</mat-icon>
}
</h4>
<div class="episode-list-item__meta">
@if (episodeInfo?.duration) {
<span class="episode-list-item__duration">
{{ episodeInfo.duration }}
</span>
}
@if (getEpisodePositionText(episode); as time) {
<span class="episode-list-item__progress-text">
{{ time }}
</span>
}
</div>
</div>
@if (episodeInfo?.plot) {
<p class="episode-list-item__description">
{{ episodeInfo.plot }}
</p>
}
@if (getEpisodeProgress(episode) > 0) {
<div class="episode-list-item__progress-bar">
<div
class="episode-list-item__progress-fill"
[style.width.%]="getEpisodeProgress(episode)"
></div>
</div>
}
</div>
<div class="episode-list-item__actions">
<button
mat-icon-button
class="action-btn play-btn"
(click)="selectEpisode(episode); $event.stopPropagation()"
[matTooltip]="'Play'"
[disabled]="isEpisodeLaunching(episode)"
>
@if (isEpisodeLaunching(episode)) {
<mat-spinner diameter="18"></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
}
</button>
@if (isElectron()) {
@if (isEpisodeDownloaded(episode)) {
<button
mat-icon-button
class="action-btn action-btn--success"
(click)="playFromLocal($event, episode)"
[matTooltip]="'DOWNLOADS.PLAY_LOCAL' | translate"
>
<mat-icon>folder_open</mat-icon>
</button>
} @else if (isEpisodeDownloading(episode)) {
<button
mat-icon-button
class="action-btn"
disabled
[matTooltip]="'DOWNLOADS.STATUS.DOWNLOADING' | translate"
>
<mat-icon>downloading</mat-icon>
</button>
} @else {
<button
mat-icon-button
class="action-btn"
(click)="downloadEpisode($event, episode)"
[matTooltip]="'DOWNLOADS.DOWNLOAD' | translate"
>
<mat-icon>download</mat-icon>
</button>
}
}
<button
mat-icon-button
class="action-btn"
(click)="toggleWatched($event, episode)"
[matTooltip]="
(isEpisodeWatched(episode)
? 'XTREAM.MARK_UNWATCHED'
: 'XTREAM.MARK_WATCHED') | translate
"
[class.action-btn--success]="isEpisodeWatched(episode)"
>
<mat-icon>{{
isEpisodeWatched(episode)
? 'check_circle'
: 'radio_button_unchecked'
}}</mat-icon>
</button>
</div>
</article>
}
</div>
}
}
</div>
</section>
@@ -0,0 +1,766 @@
.seasons-section {
width: 100%;
margin-top: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
min-height: 32px;
gap: 16px;
&__left {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.section-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.back-btn {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
padding: 6px 12px;
border-radius: 4px;
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.view-toggle {
mat-button-toggle-group {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
::ng-deep {
.mat-button-toggle {
background: transparent;
.mat-button-toggle-button {
padding: 0 8px;
height: 32px;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--text-secondary);
}
&.mat-button-toggle-checked {
background: rgba(255, 255, 255, 0.1);
mat-icon {
color: var(--accent-color);
}
}
&:hover:not(.mat-button-toggle-checked) {
background: rgba(255, 255, 255, 0.05);
}
}
.mat-button-toggle-appearance-standard {
.mat-button-toggle-label-content {
line-height: 32px;
padding: 0;
}
}
.mat-button-toggle + .mat-button-toggle {
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
}
}
}
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
padding: 30px 0;
width: 100%;
}
.empty-state-panel {
display: grid;
justify-items: center;
gap: 14px;
width: min(100%, 520px);
margin: 0 auto;
padding: 32px 28px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 18px 40px rgba(0, 0, 0, 0.22);
text-align: center;
&__icon {
display: grid;
place-items: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.52);
mat-icon {
font-size: 30px;
width: 30px;
height: 30px;
}
}
&__title {
margin: 0;
color: var(--text-primary);
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
}
&__message {
max-width: 40ch;
margin: 0;
color: var(--text-secondary);
font-size: 0.9375rem;
line-height: 1.6;
}
&--season {
border-color: rgba(255, 255, 255, 0.1);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)),
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%);
}
}
// Grid Layouts
.seasons-grid,
.episodes-grid {
display: grid;
gap: 12px;
width: 100%;
}
.seasons-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.episodes-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
// Season Card
.season-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
position: relative;
overflow: hidden;
color: var(--text-primary);
font-size: 1rem;
font-family: inherit;
&:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.15);
.season-card__arrow {
opacity: 1;
transform: translateX(0);
}
}
&__progress-ring {
position: relative;
width: 42px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.progress-ring__bg {
fill: none;
stroke: rgba(255, 255, 255, 0.1);
stroke-width: 3;
}
.progress-ring__fill {
fill: none;
stroke: var(--accent-color);
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dasharray 0.5s ease-out;
}
.season-card__number {
position: absolute;
font-size: 1rem !important;
color: var(--text-primary);
font-weight: 700;
opacity: 1;
margin: 0;
padding: 0;
background: transparent;
}
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
&__label {
font-size: 0.9375rem !important;
font-weight: 600;
color: var(--text-primary);
}
&__progress-text {
font-size: 0.75rem;
color: var(--text-muted);
}
&__episodes {
font-size: 0.75rem;
color: var(--text-muted);
}
&__arrow {
opacity: 0;
transform: translateX(-5px);
transition: all 0.2s ease;
color: var(--text-muted);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
&--has-episodes {
/* background: rgba(var(--accent-color-rgb), 0.1);
border-color: rgba(var(--accent-color-rgb), 0.3); */
.season-card__number {
color: var(--text-primary);
}
/* .season-card__label {
color: var(--accent-color);
} */
}
}
// Episode Card
.episode-card {
background: rgba(255, 255, 255, 0.03);
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid rgba(255, 255, 255, 0.05);
position: relative;
// In-progress state
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--mat-sys-primary) 10%, transparent) 0%,
transparent 50%
);
pointer-events: none;
z-index: 1;
}
}
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 56%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mat-sys-tertiary) 28%, transparent);
}
&--launching {
.episode-card__overlay {
opacity: 1;
background: rgba(0, 0, 0, 0.56);
}
}
&--active-external {
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent);
}
// Watched state
&--watched {
opacity: 0.7;
&:hover {
opacity: 1;
}
.episode-card__title::after {
content: ' ✓';
color: var(--mat-sys-tertiary);
font-weight: bold;
margin-left: 4px;
}
}
&:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.1);
.episode-card__overlay {
opacity: 1;
}
.episode-card__play {
transform: scale(1);
}
}
&__thumbnail {
position: relative;
aspect-ratio: 16 / 9;
background: #000;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__position-text {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
z-index: 2;
}
&__placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
color: var(--text-muted);
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
opacity: 0.3;
}
}
&__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 1;
}
&__actions {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
display: flex;
gap: 4px;
.download-btn,
.toggle-watched-btn {
color: rgba(255, 255, 255, 0.7);
background: rgba(0, 0, 0, 0.5);
width: 32px;
height: 32px;
padding: 4px;
&:hover {
color: white;
background: rgba(0, 0, 0, 0.8);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.download-btn:hover {
background: rgba(25, 118, 210, 0.8);
}
.download-btn--completed {
color: var(--mat-sys-tertiary) !important;
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 80%, transparent);
}
}
.download-btn--downloading {
color: rgba(255, 255, 255, 0.5) !important;
cursor: not-allowed;
}
}
&:hover {
.episode-card__actions {
opacity: 1;
}
}
&__play {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
color: white;
transform: scale(0.8);
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
&__number {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
}
&__info {
padding: 12px;
}
&__title {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 4px 0;
color: var(--text-primary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__plot {
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0 0 8px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__duration {
font-size: 0.6875rem;
color: var(--text-muted);
display: inline-block;
padding: 1px 6px;
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
}
}
// Episodes List View
.episodes-list {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
}
.episode-list-item {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
&:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.08);
.episode-list-item__actions {
opacity: 1;
}
}
&--watched {
opacity: 0.6;
&:hover {
opacity: 1;
}
}
&--in-progress {
border-color: color-mix(in srgb, var(--mat-sys-primary) 40%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 5%, transparent);
&:hover {
border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent);
background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent);
}
}
&__number {
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
&__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
&__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
.watched-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--mat-sys-tertiary);
}
}
&__meta {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
&__duration {
font-size: 0.75rem;
color: var(--text-muted);
padding: 2px 8px;
background: rgba(255, 255, 255, 0.06);
border-radius: 10px;
}
&__progress-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-primary);
padding: 2px 8px;
background: color-mix(in srgb, var(--mat-sys-primary) 15%, transparent);
border-radius: 10px;
}
&__description {
margin: 0;
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__progress-bar {
height: 3px;
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 4px;
}
&__progress-fill {
height: 100%;
background: var(--mat-sys-primary);
border-radius: 2px;
transition: width 0.3s ease;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: opacity 0.15s ease;
.action-btn {
width: 36px;
height: 36px;
color: var(--text-secondary);
transition: all 0.15s ease;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
&.play-btn {
color: var(--accent-color);
&:hover {
background: rgba(var(--accent-color-rgb), 0.15);
}
}
&--success {
color: var(--mat-sys-tertiary);
&:hover {
background: color-mix(in srgb, var(--mat-sys-tertiary) 15%, transparent);
}
}
&:disabled {
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.5;
}
}
}
&--launching,
&--active-external {
border-color: color-mix(in srgb, var(--mat-sys-tertiary) 46%, transparent);
background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent);
}
// Always show actions on touch devices
@media (hover: none) {
.episode-list-item__actions {
opacity: 1;
}
}
}
@@ -0,0 +1,162 @@
import { signal } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamSerieEpisode } from 'shared-interfaces';
import { DownloadsService } from 'services';
import { SeasonContainerComponent } from './season-container.component';
const downloadsServiceStub = {
isAvailable: signal(false),
downloads: () => [],
startDownload: async () => undefined,
isDownloaded: () => false,
isDownloading: () => false,
getDownloadedFilePath: () => '',
playDownload: async () => undefined,
};
function createEpisode(
overrides: Partial<XtreamSerieEpisode> = {}
): XtreamSerieEpisode {
return {
id: '101',
episode_num: 1,
title: 'Pilot',
container_extension: 'mp4',
info: {
duration: '45 min',
plot: 'Pilot episode',
movie_image: 'https://example.com/poster.jpg',
},
custom_sid: '',
added: '',
season: 1,
direct_source: '',
...overrides,
};
}
describe('SeasonContainerComponent', () => {
let fixture: ComponentFixture<SeasonContainerComponent>;
let component: SeasonContainerComponent;
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>,
isLoading = false
) => {
fixture.componentRef.setInput('seasons', seasons);
fixture.componentRef.setInput('seriesId', 1);
fixture.componentRef.setInput('playlistId', 'playlist-1');
fixture.componentRef.setInput('isLoading', isLoading);
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
SeasonContainerComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: DownloadsService,
useValue: downloadsServiceStub,
},
],
}).compileComponents();
fixture = TestBed.createComponent(SeasonContainerComponent);
component = fixture.componentInstance;
});
it('renders the series-level placeholder when no seasons are available', () => {
setRequiredInputs({});
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.empty-state-panel')
).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'No episodes available'
);
expect(fixture.nativeElement.textContent).toContain(
'This series is listed by your provider, but no seasons or episodes are available to play.'
);
expect(fixture.nativeElement.querySelector('.season-card')).toBeNull();
});
it('renders the loading state instead of the placeholder while seasons are loading', () => {
setRequiredInputs({}, true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.loading-container mat-spinner')
).not.toBeNull();
expect(
fixture.nativeElement.querySelector('.empty-state-panel')
).toBeNull();
});
it('renders the season-level placeholder when the selected season has no episodes', () => {
setRequiredInputs({ '1': [] });
fixture.detectChanges();
fixture.nativeElement.querySelector('.season-card').click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'This season is empty'
);
expect(fixture.nativeElement.textContent).toContain(
'This season is listed by your provider, but no playable episodes are available.'
);
expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull();
});
it('renders season cards and episode cards when episodes exist', () => {
const episode = createEpisode();
setRequiredInputs({
'1': [episode],
'2': [],
});
fixture.detectChanges();
expect(
fixture.nativeElement.querySelectorAll('.season-card').length
).toBe(2);
expect(
fixture.nativeElement.querySelector('.empty-state-panel')
).toBeNull();
fixture.nativeElement.querySelector('.season-card').click();
fixture.detectChanges();
expect(
fixture.nativeElement.querySelectorAll('.episode-card').length
).toBe(1);
expect(
fixture.nativeElement.querySelector('.empty-state-panel')
).toBeNull();
});
it('clears a stale selected season when the input seasons change', () => {
const episode = createEpisode();
setRequiredInputs({ '1': [episode] });
fixture.detectChanges();
fixture.nativeElement.querySelector('.season-card').click();
fixture.detectChanges();
fixture.componentRef.setInput('seasons', { '2': [episode] });
fixture.detectChanges();
expect(component.selectedSeason).toBeUndefined();
expect(
fixture.nativeElement.querySelector('.season-card__number')
?.textContent
).toContain('2');
});
});
@@ -0,0 +1,444 @@
import { KeyValuePipe } from '@angular/common';
import {
Component,
DoCheck,
OnInit,
inject,
input,
output,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
createLogger,
getPortalPlaybackProgressPercent,
isPortalPlaybackInProgress,
isPortalPlaybackWatched,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from 'shared-interfaces';
import { DownloadsService } from 'services';
import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../watched-badge/watched-badge.component';
type EpisodeViewMode = 'grid' | 'list';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
export interface SeasonContainerXtreamDownloadContext {
serverUrl?: string;
username?: string;
password?: string;
}
export interface SeasonContainerPlaybackToggleRequest {
contentXtreamId: number;
nextPosition: PlaybackPositionData | null;
}
function parseDuration(duration: string | number | undefined): number {
if (!duration) {
return 0;
}
if (typeof duration === 'number') {
return duration;
}
const parts = duration.split(':').map(Number);
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
return Number(duration) || 0;
}
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
imports: [
KeyValuePipe,
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
ProgressCapsuleComponent,
TranslateModule,
WatchedBadgeComponent,
],
})
export class SeasonContainerComponent implements OnInit, DoCheck {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('SeasonContainer');
private previousSeasonKeysSignature = '';
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>();
readonly playlistId = input.required<string>();
readonly seriesTitle = input<string>('');
readonly isLoading = input<boolean>(false);
readonly playbackPositions = input<Map<number, PlaybackPositionData>>(
new Map()
);
readonly xtreamDownloadContext =
input<SeasonContainerXtreamDownloadContext | null>(null);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly episodeClicked = output<XtreamSerieEpisode>();
readonly episodeDownloadRequested = output<XtreamSerieEpisode>();
readonly playbackToggleRequested =
output<SeasonContainerPlaybackToggleRequest>();
readonly seasonSelected = output<string>();
readonly isElectron = this.downloadsService.isAvailable;
readonly viewMode = signal<EpisodeViewMode>('grid');
selectedSeason: string | undefined;
ngOnInit() {
const savedMode = localStorage.getItem(
EPISODE_VIEW_MODE_KEY
) as EpisodeViewMode;
if (savedMode === 'grid' || savedMode === 'list') {
this.viewMode.set(savedMode);
}
this.previousSeasonKeysSignature = this.getSeasonKeys().join('|');
}
ngDoCheck(): void {
this.syncSelectedSeason();
}
setViewMode(mode: EpisodeViewMode) {
this.viewMode.set(mode);
localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode);
}
compareSeasons(a: { key: string }, b: { key: string }): number {
return Number(a.key) - Number(b.key);
}
hasSeasons(): boolean {
return this.getSeasonKeys().length > 0;
}
showSeriesEmptyState(): boolean {
return !this.selectedSeason && !this.hasSeasons();
}
showSeasonEmptyState(): boolean {
return Boolean(this.selectedSeason) && !this.selectedSeasonHasEpisodes();
}
selectSeason(seasonKey: string) {
this.selectedSeason = seasonKey;
this.seasonSelected.emit(seasonKey);
}
selectEpisode(episode: XtreamSerieEpisode) {
this.episodeClicked.emit(episode);
}
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.playlistId()) {
this.logger.warn('Cannot toggle watched: no playlist ID');
return;
}
const contentXtreamId = this.getEpisodeContentId(episode);
const currentPosition = this.getEpisodePosition(episode);
if (isPortalPlaybackWatched(currentPosition)) {
this.playbackToggleRequested.emit({
contentXtreamId,
nextPosition: null,
});
return;
}
const info = this.getEpisodeInfo(episode);
const duration =
info?.duration_secs || parseDuration(info?.duration) || 1;
this.playbackToggleRequested.emit({
contentXtreamId,
nextPosition: {
contentXtreamId,
contentType: 'episode',
seriesXtreamId: this.seriesId(),
seasonNumber: Number(episode.season || this.selectedSeason || 1),
episodeNumber: Number(episode.episode_num || 1),
positionSeconds: duration,
durationSeconds: duration,
playlistId: this.playlistId(),
updatedAt: new Date().toISOString(),
},
});
}
getEpisodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
if (Array.isArray(episode.info) || !episode.info) {
return undefined;
}
return episode.info;
}
isEpisodeWatched(episode: XtreamSerieEpisode): boolean {
return isPortalPlaybackWatched(this.getEpisodePosition(episode));
}
isEpisodeInProgress(episode: XtreamSerieEpisode): boolean {
return isPortalPlaybackInProgress(this.getEpisodePosition(episode));
}
isEpisodeLaunching(episode: XtreamSerieEpisode): boolean {
return this.openingEpisodeId() === this.getEpisodeContentId(episode);
}
isEpisodeActiveExternal(episode: XtreamSerieEpisode): boolean {
return this.activeEpisodeId() === this.getEpisodeContentId(episode);
}
getEpisodeProgress(episode: XtreamSerieEpisode): number {
return getPortalPlaybackProgressPercent(this.getEpisodePosition(episode));
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
if (this.isEpisodeWatched(episode)) {
return null;
}
const position = this.getEpisodePosition(episode);
if (!position || !position.positionSeconds) {
return null;
}
let seconds = position.positionSeconds;
let suffix = '';
if (position.durationSeconds && position.durationSeconds > 0) {
const remaining = Math.max(
0,
position.durationSeconds - position.positionSeconds
);
if (remaining <= 0) {
return null;
}
seconds = remaining;
suffix = ' left';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const formatted = [hours, minutes, secs]
.map((value) => String(value).padStart(2, '0'))
.filter((value, index) => (index === 0 ? value !== '00' : true))
.join(':');
return `${formatted}${suffix}`;
}
getSeasonWatchedCount(seasonKey: string): number {
const episodes = this.seasons()[seasonKey];
if (!episodes) {
return 0;
}
return episodes.filter((episode) => this.isEpisodeWatched(episode)).length;
}
getSeasonProgressDash(seasonKey: string): string {
const episodes = this.seasons()[seasonKey];
if (!episodes || episodes.length === 0) {
return '0, 100';
}
const watched = this.getSeasonWatchedCount(seasonKey);
const percent = (watched / episodes.length) * 100;
return `${percent}, 100`;
}
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (this.isStalkerEpisode(episode)) {
this.episodeDownloadRequested.emit(episode);
return;
}
const xtreamDownload = this.xtreamDownloadContext();
if (!this.playlistId() || !xtreamDownload) {
return;
}
const serverUrl = xtreamDownload.serverUrl?.replace(/\/$/, '') || '';
const username = xtreamDownload.username || '';
const password = xtreamDownload.password || '';
const extension = episode.container_extension || 'mp4';
const url = `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`;
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
const seasonNum = episode.season || Number(this.selectedSeason) || 1;
const episodeNum = episode.episode_num || 1;
const episodeTitle = `${this.seriesTitle() || 'Series'} - S${String(
seasonNum
).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: this.playlistId(),
xtreamId: Number(episode.id),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.seriesId(),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
});
}
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
return false;
}
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(
this.getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
if (!this.playlistId()) {
return false;
}
this.downloadsService.downloads();
return this.downloadsService.isDownloading(
this.getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
}
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.playlistId()) {
return;
}
const filePath = this.downloadsService.getDownloadedFilePath(
this.getEpisodeDownloadId(episode),
this.playlistId(),
'episode'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
private isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
return (
(episode as { custom_sid?: string }).custom_sid === 'vod-series' ||
(episode as { custom_sid?: string }).custom_sid === 'regular-series'
);
}
private getEpisodeContentId(episode: XtreamSerieEpisode): number {
return Number(episode.id);
}
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as { custom_sid?: string }).custom_sid;
if (customSid === 'regular-series') {
const cmd = (episode as { originalCmd?: string }).originalCmd;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
return this.hashString(cmd);
}
return Number(episode.id);
}
if (customSid === 'vod-series') {
const originalId = (episode as { originalId?: string | number })
.originalId;
const numericId = Number(originalId);
return Number.isNaN(numericId)
? this.hashString(String(originalId))
: numericId;
}
const numericId = Number(episode.id);
return Number.isNaN(numericId)
? this.hashString(String(episode.id))
: numericId;
}
private getEpisodePosition(
episode: XtreamSerieEpisode
): PlaybackPositionData | undefined {
return this.playbackPositions().get(this.getEpisodeContentId(episode));
}
private selectedSeasonHasEpisodes(): boolean {
if (!this.selectedSeason) {
return false;
}
return (this.seasons()[this.selectedSeason]?.length ?? 0) > 0;
}
private getSeasonKeys(): string[] {
return Object.keys(this.seasons());
}
private hasSeasonKey(seasonKey: string): boolean {
return Object.prototype.hasOwnProperty.call(this.seasons(), seasonKey);
}
private syncSelectedSeason(): void {
const seasonKeysSignature = this.getSeasonKeys().join('|');
if (seasonKeysSignature === this.previousSeasonKeysSignature) {
return;
}
this.previousSeasonKeysSignature = seasonKeysSignature;
if (this.selectedSeason && !this.hasSeasonKey(this.selectedSeason)) {
this.selectedSeason = undefined;
}
}
private hashString(str: string): number {
let hash = 0;
for (let index = 0; index < str.length; index++) {
const char = str.charCodeAt(index);
hash = (hash << 5) - hash + char;
hash &= hash;
}
return Math.abs(hash);
}
}
@@ -0,0 +1,588 @@
// ============================================================================
// Netflix-style Detail View - Cinematic Streaming Design
// ============================================================================
:host {
--poster-width: 220px;
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// Light theme colors (default - no .dark-theme class on body)
--hero-gradient-start: rgba(255, 250, 245, 0);
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
--hero-gradient-end: rgba(255, 250, 245, 1);
--surface-bg: #fffaf5;
--text-primary: #1a1a1a;
--text-secondary: rgba(26, 26, 26, 0.75);
--text-muted: rgba(26, 26, 26, 0.5);
--accent-color: #b81d24;
--tag-bg: rgba(0, 0, 0, 0.06);
--tag-border: rgba(0, 0, 0, 0.12);
--play-idle-bg: linear-gradient(135deg, #fffaf2 0%, #ffffff 52%, #f7ecdf 100%);
--play-idle-fg: #161310;
--play-idle-border: rgba(88, 48, 18, 0.12);
--play-idle-shadow: 0 18px 38px rgba(91, 56, 24, 0.14);
--play-launching-bg: linear-gradient(135deg, #ffd36f 0%, #ffb14c 46%, #f57d35 100%);
--play-launching-fg: #241103;
--play-launching-border: rgba(142, 66, 0, 0.2);
--play-launching-shadow: 0 22px 44px rgba(244, 136, 55, 0.32);
--play-stop-bg: linear-gradient(135deg, #ffd1c8 0%, #ff9f8d 42%, #ef5e64 100%);
--play-stop-fg: #321012;
--play-stop-border: rgba(162, 38, 45, 0.18);
--play-stop-shadow: 0 22px 46px rgba(214, 74, 86, 0.26);
--play-icon-shell-bg: rgba(255, 255, 255, 0.32);
--play-icon-shell-border: rgba(255, 255, 255, 0.3);
width: 100%;
height: 100%;
display: block;
overflow: hidden;
background: var(--surface-bg);
color: var(--text-primary);
}
// Dark theme overrides - cinematic dark mode
:host-context(.dark-theme) {
--hero-gradient-start: rgba(20, 20, 20, 0);
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
--hero-gradient-end: rgba(20, 20, 20, 1);
--surface-bg: #141414;
--text-primary: #fff;
--text-secondary: rgba(255, 255, 255, 0.7);
--text-muted: rgba(255, 255, 255, 0.5);
--accent-color: #e50914;
--tag-bg: rgba(255, 255, 255, 0.1);
--tag-border: rgba(255, 255, 255, 0.2);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
--play-idle-bg: linear-gradient(135deg, #fff5e6 0%, #f6efe5 58%, #e7dac9 100%);
--play-idle-fg: #15110d;
--play-idle-border: rgba(255, 255, 255, 0.18);
--play-idle-shadow: 0 20px 42px rgba(0, 0, 0, 0.36);
--play-launching-bg: linear-gradient(135deg, #ffdd79 0%, #ffb347 42%, #ef6a3a 100%);
--play-launching-fg: #1f1105;
--play-launching-border: rgba(255, 223, 136, 0.26);
--play-launching-shadow: 0 24px 50px rgba(255, 153, 71, 0.3);
--play-stop-bg: linear-gradient(135deg, #ffd7cf 0%, #ff9b8b 40%, #eb5961 100%);
--play-stop-fg: #2d1012;
--play-stop-border: rgba(255, 205, 205, 0.24);
--play-stop-shadow: 0 24px 50px rgba(235, 89, 97, 0.24);
--play-icon-shell-bg: rgba(255, 255, 255, 0.28);
--play-icon-shell-border: rgba(255, 255, 255, 0.24);
}
// ============================================================================
// Details Section - Content Elements
// ============================================================================
.details {
// Styles for projected content elements
&__tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
font-size: 0.8125rem;
font-weight: 500;
background: var(--tag-bg);
border: 1px solid var(--tag-border);
border-radius: 4px;
backdrop-filter: blur(8px);
&--rating {
color: #ffd700;
border-color: rgba(255, 215, 0, 0.3);
}
}
&__meta-item {
font-size: 0.875rem;
.label {
color: var(--text-muted);
font-weight: 400;
margin-bottom: 2px;
text-decoration: none;
display: block;
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
// ============================================================================
// Action Buttons - Play, Favorite
// ============================================================================
.play-btn {
--play-btn-bg: var(--play-idle-bg);
--play-btn-fg: var(--play-idle-fg);
--play-btn-border: var(--play-idle-border);
--play-btn-shadow: var(--play-idle-shadow);
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 12px 26px;
min-height: 54px;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.01em;
background: var(--play-btn-bg);
background-size: 140% 140%;
background-position: 0% 50%;
color: var(--play-btn-fg);
border: 1px solid var(--play-btn-border);
border-radius: 16px;
box-shadow: var(--play-btn-shadow);
cursor: pointer;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
background-position 280ms ease,
box-shadow 260ms ease,
border-color 220ms ease,
color 220ms ease,
filter 220ms ease;
&::before {
content: '';
position: absolute;
inset: 1px;
border-radius: inherit;
background:
linear-gradient(115deg, rgba(255, 255, 255, 0.34), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 62%);
opacity: 0.7;
transition: opacity 220ms ease;
z-index: -1;
}
&:hover:not(:disabled) {
transform: translateY(-2px);
background-position: 100% 50%;
box-shadow:
var(--play-btn-shadow),
0 10px 20px rgba(255, 255, 255, 0.16) inset;
}
&:hover:not(:disabled)::before {
opacity: 0.95;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--play-btn-shadow);
}
&:disabled {
cursor: progress;
opacity: 1;
filter: saturate(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
transition:
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
color 220ms ease,
filter 220ms ease;
}
&:hover:not(:disabled) .play-btn__icon {
transform: scale(1.08);
}
&__icon-shell {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: var(--play-icon-shell-bg);
border: 1px solid var(--play-icon-shell-border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.38),
0 6px 14px rgba(0, 0, 0, 0.08);
backdrop-filter: blur(10px);
transition:
transform 220ms ease,
background-color 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
&__label {
display: inline-flex;
align-items: center;
min-width: 0;
}
&--state-launching,
&--state-stop {
color: var(--play-btn-fg);
}
&--state-launching {
--play-btn-bg: var(--play-launching-bg);
--play-btn-fg: var(--play-launching-fg);
--play-btn-border: var(--play-launching-border);
--play-btn-shadow: var(--play-launching-shadow);
animation: playButtonPulse 1.8s ease-in-out infinite;
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.42),
0 10px 22px rgba(184, 89, 26, 0.2);
}
.play-btn__icon {
animation: playIconLaunch 1.15s cubic-bezier(0.55, 0, 0.45, 1) infinite;
}
}
&--state-stop {
--play-btn-bg: var(--play-stop-bg);
--play-btn-fg: var(--play-stop-fg);
--play-btn-border: var(--play-stop-border);
--play-btn-shadow: var(--play-stop-shadow);
.play-btn__icon-shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.4),
0 12px 24px rgba(145, 38, 53, 0.18);
}
.play-btn__icon {
animation: playIconStop 1.2s ease-in-out infinite;
}
}
}
@keyframes playButtonPulse {
0%,
100% {
box-shadow: var(--play-btn-shadow);
}
50% {
box-shadow:
var(--play-btn-shadow),
0 0 0 6px rgba(255, 190, 92, 0.14);
}
}
@keyframes playIconLaunch {
0%,
100% {
transform: rotate(0deg) scale(1);
}
50% {
transform: rotate(18deg) scale(1.1);
}
}
@keyframes playIconStop {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.play-btn,
.play-btn::before,
.play-btn mat-icon,
.play-btn__icon-shell {
animation: none !important;
transition-duration: 1ms !important;
}
}
.favorite-btn,
.download-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
font-size: 0.9375rem;
font-weight: 500;
background: rgba(109, 109, 110, 0.7);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.favorite-btn {
&--active {
background: rgba(229, 9, 20, 0.8);
&:hover {
background: rgba(229, 9, 20, 0.6);
}
}
}
.download-btn {
background: rgba(33, 150, 243, 0.8);
&:hover {
background: rgba(33, 150, 243, 0.6);
}
&--completed {
background: rgba(76, 175, 80, 0.8);
&:hover {
background: rgba(76, 175, 80, 0.6);
}
}
&--downloading {
background: rgba(109, 109, 110, 0.5);
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: rgba(109, 109, 110, 0.5);
}
}
}
// ============================================================================
// Content Sections - Below the hero fold
// ============================================================================
.content-section {
padding: 20px 0;
width: 100%;
box-sizing: border-box;
&__title {
font-size: 1.375rem;
font-weight: 600;
margin: 0 0 20px 0;
color: var(--text-primary);
}
}
// ============================================================================
// Seasons & Episodes - Inside details section
// ============================================================================
.seasons {
margin-top: 24px;
width: 100%;
.season-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.expand-icon {
font-size: 0.75rem;
color: var(--text-muted);
}
}
.episodes {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px 0 16px;
button {
margin: 0;
font-weight: 500;
}
}
.episodes-loading,
.seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
}
.no-episodes {
color: var(--text-muted);
font-style: italic;
padding: 8px 0;
}
}
// Seasons loading when inside details
.details .seasons-loading {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 0;
color: var(--text-secondary);
margin-top: 24px;
}
// Season container when inside details section
.details app-season-container {
display: block;
width: 100%;
margin-top: 24px;
box-sizing: border-box;
}
// ============================================================================
// YouTube Trailer Section - Inside details
// ============================================================================
.youtube-trailer {
margin-top: 24px;
width: 100%;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.trailer-wrapper {
position: relative;
width: 100%;
max-width: 560px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}
}
// ============================================================================
// Legacy container support (for gradual migration)
// ============================================================================
.container {
display: contents;
}
.image {
display: none; // Hidden - now using .poster in hero
}
// ============================================================================
// Mobile Responsive - Option A: Keep poster grid layout
// ============================================================================
@media (max-width: 800px) {
:host {
--poster-width: 120px;
}
.action-buttons {
width: 100%;
// This class is in ContentHeroComponent now, but these rules apply to children?
// No, .play-btn is a child of the PROJECTED content.
// So we should target the buttons directly for mobile
}
.play-btn,
.favorite-btn,
.download-btn {
flex: 1;
justify-content: center;
padding: 10px 16px;
}
.youtube-trailer {
margin-top: 16px;
.trailer-wrapper {
max-width: 100%;
border-radius: 4px;
}
}
.seasons {
padding: 16px 0 0;
}
}
// ============================================================================
// Animations
// ============================================================================
.youtube-trailer {
animation: fadeInUp 0.5s ease-out 0.4s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// ============================================================================
// Utility - keep .label for backwards compatibility
// ============================================================================
.label {
font-weight: 400;
font-size: 0.8125rem;
color: var(--text-muted);
text-decoration: none;
padding: 0;
opacity: 1;
}
@@ -0,0 +1,44 @@
import { Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'app-watched-badge',
standalone: true,
imports: [MatIcon],
template: `
@if (isWatched()) {
<div class="watched-badge">
<mat-icon>{{ icon() }}</mat-icon>
</div>
}
`,
styles: [
`
.watched-badge {
position: absolute;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
background: rgba(70, 211, 105, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: white;
}
}
`,
],
})
export class WatchedBadgeComponent {
readonly isWatched = input.required<boolean>();
readonly icon = input<string>('check_circle');
}
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"files": ["../../../global.d.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}