Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# shared-portals This library was generated with [Nx](https://nx.dev). ##
Running unit tests Run `nx test shared-portals` to execute the unit tests.
+34
View File
@@ -0,0 +1,34 @@
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: {},
},
];
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'shared-portals',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/ui/shared-portals',
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',
]
};
+27
View File
@@ -0,0 +1,27 @@
{
"name": "shared-portals",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/ui/shared-portals/src",
"prefix": "lib",
"projectType": "library",
"tags": [
"scope:portal",
"domain:portal-shared",
"type:ui"
],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": [
"{workspaceRoot}/coverage/{projectRoot}"
],
"options": {
"jestConfig": "libs/ui/shared-portals/jest.config.ts",
"tsConfig": "libs/ui/shared-portals/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './lib/epg-view/epg-view.component';
export * from './lib/live-stream-layout/live-stream-layout.component';
export { WebPlayerViewComponent } from '@iptvnator/ui/playback';
@@ -0,0 +1,49 @@
<div class="epg-title">{{ 'EPG.TITLE' | translate }}</div>
<div class="epg-list">
@for (item of epgItems; track $index) {
<article class="epg-item" [class.current-program]="isCurrentProgram(item)">
<div class="epg-item__meta">
<div class="epg-item__time">
{{ item.start | date: 'HH:mm' }} -
{{ item.stop ?? item.end | date: 'HH:mm' }}
</div>
@if (isCurrentProgram(item)) {
<span class="epg-item__badge">Live</span>
}
<button
mat-icon-button
class="epg-item__info"
(click)="showDetails(item)"
>
<mat-icon>info</mat-icon>
</button>
</div>
<div class="epg-item__title">{{ item.title }}</div>
@if (item.description) {
<div class="epg-item__description">
{{ item.description }}
</div>
}
@if (isCurrentProgram(item)) {
<div class="epg-item__progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="getProgress(item)"
></div>
</div>
</div>
}
</article>
}
@if (epgItems?.length === 0) {
<div class="epg-empty">
{{ 'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate }}
</div>
}
</div>
@@ -0,0 +1,188 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.epg-title {
margin: 0;
padding: 14px 16px 10px;
font-size: 1.02rem;
font-weight: 620;
letter-spacing: 0.01em;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 2;
}
.epg-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 10px 22px;
box-sizing: border-box;
scrollbar-gutter: stable;
}
.epg-item {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.045);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 10px 20px -20px rgba(0, 0, 0, 0.35);
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
&:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.065);
transform: translateY(-1px);
}
&.current-program {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
.epg-item__title,
.epg-item__time,
.epg-item__description,
.epg-item__info {
color: var(--app-selection-color);
}
}
}
.epg-item__meta {
display: flex;
align-items: center;
gap: 8px;
}
.epg-item__time {
font-size: 0.78rem;
line-height: 1;
font-weight: 600;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
opacity: 0.76;
}
.epg-item__badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
line-height: 1.4;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
background: color-mix(in srgb, var(--app-selection-color) 18%, transparent);
color: var(--app-selection-color);
border: 1px solid color-mix(in srgb, var(--app-selection-border) 86%, transparent);
}
.epg-item__info {
margin-inline-start: auto;
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);
}
}
.epg-item__title {
font-size: 0.94rem;
line-height: 1.35;
font-weight: 560;
letter-spacing: 0.005em;
}
.epg-item__description {
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.74;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.epg-item__progress {
padding-top: 4px;
}
.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-empty {
padding: 22px;
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 14px;
text-align: center;
font-size: 0.88rem;
opacity: 0.66;
}
@@ -0,0 +1,50 @@
import { DatePipe } from '@angular/common';
import { Component, Input, inject } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { TranslateModule } from '@ngx-translate/core';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { EpgItem } from 'shared-interfaces';
@Component({
selector: 'app-epg-view',
templateUrl: './epg-view.component.html',
imports: [DatePipe, MatIconButton, MatIcon, MatListModule, TranslateModule],
styleUrls: ['./epg-view.component.scss'],
})
export class EpgViewComponent {
@Input() epgItems: EpgItem[] = [];
dialog = inject(MatDialog);
isCurrentProgram(item: EpgItem): boolean {
const end = item.stop ?? item.end;
const now = new Date().getTime();
const start = new Date(item.start).getTime();
const stop = new Date(end).getTime();
return now >= start && now <= stop;
}
getProgress(item: EpgItem): number {
const now = new Date().getTime();
const start = new Date(item.start).getTime();
const end = new Date(item.stop ?? item.end).getTime();
const total = end - start;
const current = now - start;
return Math.min(Math.max((current / total) * 100, 0), 100);
}
showDetails(item: EpgItem) {
this.dialog.open(EpgItemDescriptionComponent, {
width: '600px',
data: {
title: item.title ?? 'No title',
desc: item.description ?? 'No description',
},
});
}
}
@@ -0,0 +1,79 @@
<div
[class]="
player === 'videojs' || player === 'html5'
? 'itv-container'
: 'itv-container-epg'
"
>
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-content">
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</mat-form-field>
</div>
<div class="nav-list" style="height: calc(100% - 70px)">
<cdk-virtual-scroll-viewport
[itemSize]="56"
class="scroll-viewport-portals"
>
<div
class="nav-item"
*cdkVirtualFor="
let item of channels
| filterBy: searchString() : 'name';
trackBy: trackBy;
templateCacheSize: 0
"
[class.selected]="
item.stream_id === activeLiveStream?.stream_id
"
(click)="itemClicked.emit(item)"
>
@if (item.stream_icon) {
<img
class="stream-icon"
[src]="item.stream_icon"
alt="channel icon"
/>
}
<div class="title">
{{ item.name }}
</div>
</div>
</cdk-virtual-scroll-viewport>
</div>
</div>
</div>
@if (player === 'videojs' || player === 'html5') {
@if (streamUrl) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl" />
</div>
}
}
@if (streamUrl) {
<div class="epg">
<div class="epg-content">
<app-epg-view [epgItems]="epgItems" />
</div>
</div>
} @else {
<div class="no-channel-selected">
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
</div>
}
</div>
@@ -0,0 +1,201 @@
@use '@angular/material' as mat;
:host {
width: 100%;
height: 100%;
min-height: 0;
display: block;
}
.itv-container {
display: grid;
// Use auto for sidebar to respect the directive's width
grid-template-columns: auto 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 0 0;
grid-auto-flow: row;
grid-template-areas:
'sidebar video-player video-player'
'sidebar epg epg';
height: 100%;
}
.itv-container-epg {
display: grid;
// Use auto for sidebar to respect the directive's width
grid-template-columns: auto 1fr 1fr;
grid-template-rows: repeat(3, minmax(0, 1fr));
gap: 0 0;
grid-auto-flow: row;
grid-template-areas:
'sidebar epg epg'
'sidebar epg epg'
'sidebar epg epg';
height: 100%;
}
.sidebar {
grid-area: sidebar;
// Default width (controlled by resizable directive)
width: 400px;
min-width: 250px;
max-width: 600px;
flex-shrink: 0;
position: relative;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
}
.video-player {
grid-area: video-player;
min-height: 0;
overflow: hidden;
display: flex;
}
.video-player > app-web-player-view {
flex: 1;
min-height: 0;
}
.epg {
grid-area: epg;
min-height: 0;
overflow: hidden;
}
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -15px;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
.epg-content {
height: 100%;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
border-right: #111 1px solid;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
}
.sidebar-content {
height: 100%;
overflow-y: auto;
border-right: #111 1px solid;
overflow-x: hidden;
}
.no-channel-selected {
margin: 10px;
}
::ng-deep {
.search-bar {
div {
border-radius: 20px;
}
}
}
.nav-list {
flex: 1;
overflow-y: hidden;
padding: 0 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
text-align: left;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
box-sizing: border-box;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
.stream-icon {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent);
}
span,
.title {
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
&.selected,
&.active {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
) !important;
color: var(--app-selection-color) !important;
border-color: var(--app-selection-border) !important;
font-weight: 600;
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
* {
color: var(--app-selection-color) !important;
}
&::before {
content: '';
position: absolute;
inset-inline-start: 6px;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 999px;
background: var(--app-selection-color);
box-shadow: 0 0 10px var(--app-selection-glow);
}
}
}
@@ -0,0 +1,56 @@
import { ScrollingModule } from '@angular/cdk/scrolling';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { FilterPipe } from '@iptvnator/pipes';
import { TranslateModule } from '@ngx-translate/core';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { ResizableDirective } from 'components';
import { EpgItem, VideoPlayer, XtreamItem } from 'shared-interfaces';
import { EpgViewComponent } from '../epg-view/epg-view.component';
@Component({
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
styleUrls: ['./live-stream-layout.component.scss'],
imports: [
EpgViewComponent,
FilterPipe,
FormsModule,
MatListModule,
MatIconModule,
MatInputModule,
MatFormFieldModule,
ResizableDirective,
ScrollingModule,
WebPlayerViewComponent,
TranslateModule
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LiveStreamLayoutComponent {
@Input({ required: true }) channels!: XtreamItem[];
@Input({ required: true }) player: VideoPlayer = VideoPlayer.VideoJs;
@Input() epgItems!: EpgItem[];
@Input() streamUrl!: string;
@Input() activeLiveStream!: XtreamItem;
@Output() itemClicked = new EventEmitter<XtreamItem>();
searchString = signal<string>('');
trackBy(_index: number, item: XtreamItem) {
return item.stream_id;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true
});
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": [
"src/**/*.ts"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"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"
]
}