Proyecto App Linux
This commit is contained in:
@@ -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.
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
+60
@@ -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>
|
||||
+105
@@ -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 */
|
||||
}
|
||||
+157
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+195
@@ -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)',
|
||||
});
|
||||
}
|
||||
}
|
||||
+233
@@ -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>
|
||||
+390
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
@@ -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',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
+185
@@ -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 };
|
||||
}
|
||||
}
|
||||
+98
@@ -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>
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
+141
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+439
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
@@ -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>
|
||||
+301
@@ -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;
|
||||
}
|
||||
}
|
||||
+121
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+111
@@ -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 || '';
|
||||
}
|
||||
+52
@@ -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>
|
||||
+35
@@ -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;
|
||||
}
|
||||
+136
@@ -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));
|
||||
}
|
||||
}
|
||||
+85
@@ -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>
|
||||
+150
@@ -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;
|
||||
}
|
||||
+120
@@ -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);
|
||||
}
|
||||
}
|
||||
+295
@@ -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>
|
||||
+284
@@ -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;
|
||||
}
|
||||
}
|
||||
+609
@@ -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();
|
||||
});
|
||||
});
|
||||
+590
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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>
|
||||
+35
@@ -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;
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
+98
@@ -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>
|
||||
+253
@@ -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;
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
});
|
||||
});
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+71
@@ -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>
|
||||
+192
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
@@ -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');
|
||||
});
|
||||
});
|
||||
+105
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user