Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
@@ -0,0 +1,3 @@
# feature
This library was generated with [Nx](https://nx.dev).
@@ -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: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
@@ -0,0 +1,22 @@
export default {
displayName: 'workspace-dashboard-feature',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/dashboard/feature',
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',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
@@ -0,0 +1,21 @@
{
"name": "workspace-dashboard-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/dashboard/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/workspace/dashboard/feature/jest.config.ts",
"tsConfig": "libs/workspace/dashboard/feature/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,2 @@
export * from './lib/rails/workspace-dashboard-rails.component';
export * from './lib/rails/dashboard-rail.component';
@@ -0,0 +1,136 @@
<section class="rail" [attr.data-test-id]="testId()">
<header class="rail__header">
<h2 class="rail__title">
<span class="rail__label">{{ label() }}</span>
@if ((totalCount() ?? items().length) > 0) {
<span class="rail__count" aria-hidden="true">{{
totalCount() ?? items().length
}}</span>
}
</h2>
@if (seeAllLink(); as link) {
<a
class="rail__see-all"
[routerLink]="link"
[attr.data-test-id]="testId() ? testId() + '-manage-all' : null"
>
<span class="rail__see-all-label">{{
'WORKSPACE.DASHBOARD.MANAGE_ALL' | translate
}}</span>
<mat-icon>chevron_right</mat-icon>
</a>
}
</header>
<div class="rail__viewport">
<button
type="button"
class="rail__chev rail__chev--left"
[class.rail__chev--visible]="canScrollLeft()"
(click)="scrollBy(-1)"
aria-label="Scroll left"
tabindex="-1"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div #track class="rail__track" (scroll)="onScroll()" role="list">
@for (card of items(); track card.id) {
<div
class="rail__card"
role="listitem"
[attr.title]="card.title"
[attr.data-test-id]="testId() ? testId() + '-card' : null"
>
<a
class="rail__card-link"
[routerLink]="card.link"
[state]="card.state"
>
<div
class="rail__art"
[style.aspect-ratio]="aspectRatio()"
>
@if (card.imageUrl && !failedImages()[card.id]) {
<img
[src]="card.imageUrl"
[alt]="card.title"
loading="lazy"
decoding="async"
(error)="markFailed(card.id)"
/>
} @else {
<div class="rail__art-fallback">
<mat-icon>{{ card.icon }}</mat-icon>
</div>
}
</div>
<div class="rail__meta">
<span class="rail__card-title">{{
card.title
}}</span>
@if (card.subtitle) {
<span class="rail__card-subtitle">{{
card.subtitle
}}</span>
}
</div>
</a>
@if (card.actions && card.actions.length > 0) {
<button
type="button"
mat-icon-button
class="rail__action-trigger"
[matMenuTriggerFor]="cardActionMenu"
[matMenuTriggerData]="{ card: card }"
[attr.aria-label]="'SHOW_MORE' | translate"
[attr.data-test-id]="
testId() ? testId() + '-card-actions' : null
"
(click)="stopActionEvent($event)"
>
<mat-icon>more_vert</mat-icon>
</button>
}
</div>
}
</div>
<mat-menu
#cardActionMenu="matMenu"
panelClass="dashboard-rail-action-menu"
xPosition="before"
>
<ng-template matMenuContent let-card="card">
@for (action of card.actions ?? []; track action.id) {
<button
mat-menu-item
type="button"
[disabled]="action.disabled"
[class.rail__menu-action--danger]="action.destructive"
[class.rail__menu-action--separated]="
action.separatorBefore
"
(click)="selectAction(card, action, $event)"
>
<mat-icon>{{ action.icon }}</mat-icon>
<span>{{ action.labelKey | translate }}</span>
</button>
}
</ng-template>
</mat-menu>
<button
type="button"
class="rail__chev rail__chev--right"
[class.rail__chev--visible]="canScrollRight()"
(click)="scrollBy(1)"
aria-label="Scroll right"
tabindex="-1"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</section>
@@ -0,0 +1,421 @@
:host {
display: block;
--rail-card-width: 172px;
--rail-gap: 14px;
--rail-radius: 12px;
}
.rail {
display: flex;
flex-direction: column;
gap: 12px;
}
.rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
// Tighter height cap — keeps rail chapter markers compact
min-height: 24px;
}
.rail__title {
margin: 0;
display: inline-flex;
align-items: baseline;
gap: 8px;
font-size: 1.125rem; // 18px — matches Netflix rail-title scale
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.012em;
color: var(--app-heading-color, #1a1d23);
}
// Muted count badge, tabular numerals for alignment, sits just after label.
.rail__count {
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
color: var(--app-muted-color, #8891a4);
font-variant-numeric: tabular-nums;
// Tiny capsule to read as a "count chip", not a number-in-text
padding: 2px 8px;
border-radius: 999px;
background: color-mix(
in srgb,
var(--app-muted-color, #8891a4) 18%,
transparent
);
letter-spacing: 0;
}
.rail__see-all {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 4px 8px 4px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 500;
line-height: 1;
letter-spacing: 0.005em;
color: var(--app-muted-color, #8891a4);
text-decoration: none;
transition:
color 0.15s ease,
background-color 0.15s ease;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
transition: transform 0.15s ease;
}
&:hover {
color: var(--app-selection-color, #2f7bff);
background: color-mix(
in srgb,
var(--app-selection-color, #2f7bff) 12%,
transparent
);
mat-icon {
transform: translateX(2px);
}
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 2px;
}
}
.rail__viewport {
position: relative;
min-width: 0;
// Soft edge gradients hint at horizontal scroll without any interaction.
// Anchored to the viewport so they appear/disappear in sync with the
// chevrons' `.rail__chev--visible` state.
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease;
}
&::before {
left: 0;
background: linear-gradient(
to right,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
&::after {
right: 0;
background: linear-gradient(
to left,
var(--app-content-bg, #161a22) 0%,
transparent 100%
);
}
// Show the correct edge gradient when scroll is possible in that direction
&:has(.rail__chev--left.rail__chev--visible)::before {
opacity: 0.9;
}
&:has(.rail__chev--right.rail__chev--visible)::after {
opacity: 0.9;
}
&:hover .rail__chev--visible {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
.rail__track {
display: flex;
align-items: flex-start;
gap: var(--rail-gap);
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: 4px;
scrollbar-width: none;
padding: 4px;
margin: -4px;
&::-webkit-scrollbar {
display: none;
}
}
.rail__card {
// Pin the card size so long titles never expand the flex item.
// `min-width: 0` is needed because flex items default to `min-width: auto`
// (their intrinsic content size), which would otherwise override the
// flex-basis when the title wraps.
flex: 0 0 var(--rail-card-width);
width: var(--rail-card-width);
max-width: var(--rail-card-width);
min-width: 0;
scroll-snap-align: start;
position: relative;
display: flex;
flex-direction: column;
color: inherit;
border-radius: var(--rail-radius);
transition:
transform 0.2s ease,
filter 0.2s ease;
&:hover {
transform: translateY(-2px);
.rail__art {
box-shadow:
0 12px 22px -12px rgba(0, 0, 0, 0.45),
0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.4));
}
.rail__card-title {
color: var(--app-selection-color, #2f7bff);
}
}
}
.rail__card-link {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
color: inherit;
text-decoration: none;
border-radius: inherit;
&:focus-visible {
outline: 2px solid var(--app-selection-color, #2f7bff);
outline-offset: 3px;
}
}
.rail__art {
position: relative;
overflow: hidden;
border-radius: var(--rail-radius);
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.rail__art-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(
circle at 30% 20%,
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
transparent 55%
),
var(--app-widget-bg, #1e2330);
mat-icon {
font-size: 44px;
width: 44px;
height: 44px;
color: var(--app-selection-color, #78adff);
opacity: 0.55;
}
}
.rail__action-trigger {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
width: 30px;
height: 30px;
padding: 3px;
border-radius: 50%;
color: #fff;
background: transparent;
border: 0;
box-shadow: none;
opacity: 0.92;
transition:
opacity 0.15s ease,
transform 0.15s ease;
--mdc-icon-button-state-layer-size: 30px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
line-height: 18px;
}
&:hover,
&:focus-visible {
opacity: 1;
transform: translateY(-1px);
}
}
.rail__menu-action--separated {
border-top: 1px solid var(--app-separator);
margin-top: 4px;
}
.rail__menu-action--danger {
color: var(--mat-sys-error);
mat-icon {
color: var(--mat-sys-error);
}
}
.rail__meta {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 2px;
min-width: 0;
}
.rail__card-title {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.3;
color: var(--app-heading-color, #1a1d23);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.005em;
transition: color 0.15s ease;
}
.rail__card-subtitle {
font-size: 0.72rem;
font-weight: 500;
line-height: 1.4;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.005em;
}
// Circular chevron — streaming-app pattern (Netflix/Apple TV+). High-contrast
// dark glass works against any card color, including the muted fallback tiles
// which previously camouflaged the buttons.
.rail__chev {
position: absolute;
top: 50%;
transform: translateY(-50%) scale(0.96);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition:
opacity 0.18s ease,
background-color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
z-index: 2;
box-shadow:
0 4px 14px -4px rgba(0, 0, 0, 0.55),
0 0 0 0 rgba(255, 255, 255, 0); // slot for hover ring
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
line-height: 24px;
// Keep icon crisp over bright content
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
}
// Peek at rest when scroll is possible — hints the affordance exists
// without requiring the user to hover first.
&--visible {
opacity: 0.5;
pointer-events: auto;
}
&:hover {
background: rgba(0, 0, 0, 0.88);
transform: translateY(-50%) scale(1.08);
box-shadow:
0 8px 22px -6px rgba(0, 0, 0, 0.65),
0 0 0 4px rgba(255, 255, 255, 0.06);
}
&:active {
transform: translateY(-50%) scale(0.96);
}
&:focus-visible {
outline: 2px solid var(--app-selection-color, #78adff);
outline-offset: 3px;
}
&--left {
left: 6px;
}
&--right {
right: 6px;
}
}
@media (hover: none) {
.rail__chev {
display: none;
}
.rail__viewport::before,
.rail__viewport::after {
display: none;
}
}
@media (max-width: 720px) {
:host {
--rail-card-width: 140px;
}
}
@@ -0,0 +1,166 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
OnDestroy,
effect,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
export interface DashboardRailAction {
id: string;
labelKey: string;
icon: string;
destructive?: boolean;
disabled?: boolean;
separatorBefore?: boolean;
}
export interface DashboardRailCard {
id: string;
title: string;
subtitle?: string;
imageUrl?: string;
icon: string;
link: string[];
state?: Record<string, unknown>;
actions?: DashboardRailAction[];
}
export interface DashboardRailActionSelection {
action: DashboardRailAction;
card: DashboardRailCard;
}
@Component({
selector: 'lib-dashboard-rail',
imports: [
MatButtonModule,
MatIcon,
MatMenuModule,
RouterLink,
TranslatePipe,
],
templateUrl: './dashboard-rail.component.html',
styleUrl: './dashboard-rail.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardRailComponent implements AfterViewInit, OnDestroy {
readonly label = input.required<string>();
readonly items = input.required<DashboardRailCard[]>();
readonly seeAllLink = input<string[] | null>(null);
readonly aspectRatio = input<string>('2 / 3');
readonly testId = input<string | null>(null);
readonly actionSelected = output<DashboardRailActionSelection>();
/**
* True total in the underlying dataset. Shown as a count badge next to
* the rail label. Falls back to `items().length` when not supplied.
*/
readonly totalCount = input<number | null>(null);
private readonly track =
viewChild.required<ElementRef<HTMLDivElement>>('track');
readonly canScrollLeft = signal(false);
readonly canScrollRight = signal(false);
readonly failedImages = signal<Record<string, true>>({});
private readonly viewReady = signal(false);
private resizeObserver?: ResizeObserver;
private resetFrameId: number | null = null;
private settleFrameId: number | null = null;
constructor() {
effect(() => {
this.items();
if (!this.viewReady()) return;
this.scheduleResetToStart();
});
}
ngAfterViewInit(): void {
this.viewReady.set(true);
this.updateScrollState();
this.resizeObserver = new ResizeObserver(() =>
this.updateScrollState()
);
this.resizeObserver.observe(this.track().nativeElement);
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.cancelPendingReset();
}
onScroll(): void {
this.updateScrollState();
}
scrollBy(direction: 1 | -1): void {
const el = this.track().nativeElement;
el.scrollBy({
left: direction * el.clientWidth * 0.85,
behavior: 'smooth',
});
}
markFailed(id: string): void {
this.failedImages.update((state) =>
state[id] ? state : { ...state, [id]: true }
);
}
stopActionEvent(event: Event): void {
event.stopPropagation();
}
selectAction(
card: DashboardRailCard,
action: DashboardRailAction,
event: Event
): void {
event.stopPropagation();
this.actionSelected.emit({ card, action });
}
private updateScrollState(): void {
const el = this.track().nativeElement;
this.canScrollLeft.set(el.scrollLeft > 4);
this.canScrollRight.set(
el.scrollLeft + el.clientWidth < el.scrollWidth - 4
);
}
private scheduleResetToStart(): void {
this.cancelPendingReset();
this.resetFrameId = requestAnimationFrame(() => {
this.resetFrameId = null;
this.settleFrameId = requestAnimationFrame(() => {
this.settleFrameId = null;
const el = this.track().nativeElement;
el.scrollTo({ left: 0, behavior: 'auto' });
this.updateScrollState();
});
});
}
private cancelPendingReset(): void {
if (this.resetFrameId !== null) {
cancelAnimationFrame(this.resetFrameId);
this.resetFrameId = null;
}
if (this.settleFrameId !== null) {
cancelAnimationFrame(this.settleFrameId);
this.settleFrameId = null;
}
}
}
@@ -0,0 +1,160 @@
<section
class="rails-page"
[class.rails-page--empty]="ready() && !hasPlaylists()"
data-test-id="dashboard-page"
>
@if (!ready()) {
<!-- Precise ghosts that mirror the real hero + rails so the layout
doesn't reflow when data arrives. -->
<section
class="rails-page__skeleton-hero"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-hero-poster"></div>
<div class="rails-page__skeleton-hero-body">
<div class="rails-page__skeleton-hero-title"></div>
<div
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
></div>
<div class="rails-page__skeleton-hero-subtitle"></div>
<div class="rails-page__skeleton-hero-cta"></div>
</div>
</section>
@for (_ of skeletonRails; track $index) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
}
} @else if (!hasPlaylists()) {
<app-empty-state
[type]="'welcome-dashboard'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
@if (hero(); as item) {
<a
class="hero"
data-test-id="dashboard-hero"
[class.hero--with-image]="
!!item.posterUrl || !!item.backdropUrl
"
[class.hero--has-backdrop]="item.hasBackdrop"
[class.hero--has-default-backdrop]="
item.backdropSource === 'fallback'
"
[routerLink]="item.link"
[state]="item.state ?? {}"
>
<span
class="hero__backdrop"
aria-hidden="true"
[style.backgroundImage]="
item.backdropUrl
? 'url(' + item.backdropUrl + ')'
: item.fallbackBackdropBackground
"
></span>
@if (item.backdropUrl; as backdropUrl) {
<img
class="hero__image-probe"
[src]="backdropUrl"
alt=""
aria-hidden="true"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(backdropUrl)"
/>
}
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__poster">
@if (item.posterUrl; as posterUrl) {
<img
[src]="posterUrl"
[alt]="item.title"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(posterUrl)"
/>
} @else {
<span
class="hero__poster-fallback"
[style.backgroundImage]="
item.fallbackPosterBackground
"
>
<mat-icon>{{ item.icon }}</mat-icon>
</span>
}
</span>
<span class="hero__body">
<h1 class="hero__title">{{ item.title }}</h1>
<p class="hero__subtitle">{{ item.subtitle }}</p>
<span class="hero__cta">
<mat-icon>play_arrow</mat-icon>
<span>{{
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' | translate
}}</span>
</span>
</span>
</a>
}
@if (recentlyWatchedCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENTLY_WATCHED' | translate"
[items]="recentlyWatchedCards()"
[totalCount]="data.globalRecentItems().length"
[seeAllLink]="['/workspace/global-recent']"
[testId]="'dashboard-recently-watched-rail'"
/>
}
@if (favoriteCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.GLOBAL_FAVORITES' | translate"
[items]="favoriteCards()"
[totalCount]="data.globalFavoriteItems().length"
[seeAllLink]="['/workspace/global-favorites']"
[testId]="'dashboard-global-favorites-rail'"
/>
}
@if (sourceCards().length > 0) {
<lib-dashboard-rail
[label]="'WORKSPACE.DASHBOARD.RECENT_SOURCES' | translate"
[items]="sourceCards()"
[totalCount]="data.playlists().length"
[seeAllLink]="['/workspace/sources']"
aspectRatio="16 / 9"
[testId]="'dashboard-recent-sources-rail'"
(actionSelected)="onSourceActionSelected($event)"
/>
}
@if (xtreamRecentlyAddedCards().length > 0) {
<lib-dashboard-rail
[label]="
'WORKSPACE.DASHBOARD.XTREAM_RECENTLY_ADDED' | translate
"
[items]="xtreamRecentlyAddedCards()"
[totalCount]="data.xtreamRecentlyAddedItems().length"
[testId]="'dashboard-xtream-recently-added-rail'"
/>
}
}
</section>
@@ -0,0 +1,463 @@
:host {
display: block;
padding: 24px 28px 40px;
box-sizing: border-box;
min-height: 100%;
}
:host(.rails-page-host--empty) {
padding: 0;
height: 100%;
min-height: 100%;
display: flex;
overflow: hidden;
}
.rails-page {
max-width: 1440px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 32px;
min-width: 0;
}
.rails-page--empty {
flex: 1;
max-width: none;
margin: 0;
gap: 0;
min-height: 0;
height: 100%;
overflow: hidden;
}
// ── Hero ────────────────────────────────────────────────────────────────────
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
// poster card + text block. Mirrors content-hero fallback (no dedicated
// backdrop asset available, so we reuse the poster blurred + scaled).
.hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
gap: 24px;
padding: 20px;
text-decoration: none;
color: inherit;
border-radius: 18px;
overflow: hidden;
isolation: isolate;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 12px 32px -18px var(--app-selection-glow, rgba(120, 173, 255, 0.4));
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
--hero-poster-width: 160px;
&:hover {
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 20px 40px -20px
var(--app-selection-glow, rgba(120, 173, 255, 0.55));
}
// No-image fallback keeps the accent gradient so the hero is never flat.
&:not(.hero--with-image) {
background:
var(
--app-accent-gradient,
linear-gradient(
135deg,
rgba(99, 102, 241, 0.12) 0%,
rgba(168, 85, 247, 0.07) 100%
)
),
var(--app-widget-header-bg, #232840);
}
}
// Layer 0: backdrop. Two modes:
// (1) Real backdrop (`.hero--has-backdrop`): sharp wide image with no blur.
// (2) Blurred-poster fallback: heavy blur + scale so the poster's colours
// wash across the full panel without the portrait edges showing.
.hero__backdrop {
position: absolute;
inset: 0;
background-size: cover;
background-position: center 25%;
background-repeat: no-repeat;
z-index: 0;
pointer-events: none;
// Fallback: blurred poster.
filter: blur(40px) saturate(1.5);
transform: scale(1.5);
opacity: 0.9;
}
.hero--has-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 1;
}
.hero--has-default-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 0.95;
}
.hero__image-probe {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
opacity: 0;
pointer-events: none;
}
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
// right edge (text legibility). Middle stays mostly transparent so the
// backdrop colours show through. Darkens further when a sharp backdrop is
// present, since real photography has more focal variation than a blur wash.
.hero__vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
linear-gradient(
90deg,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 55%,
transparent
)
0%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 10%,
transparent
)
45%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 65%,
transparent
)
100%
),
linear-gradient(
to bottom,
transparent 55%,
color-mix(
in srgb,
var(--app-widget-header-bg, #232840) 35%,
transparent
)
100%
);
}
.hero--has-backdrop .hero__vignette {
background:
linear-gradient(
90deg,
rgba(0, 0, 0, 0.55) 0%,
rgba(0, 0, 0, 0.2) 35%,
rgba(0, 0, 0, 0.78) 100%
),
linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.45) 100%
);
}
// Layer 2: sharp poster in native 2:3 aspect (no more 16:9 crop).
.hero__poster {
position: relative;
z-index: 2;
width: var(--hero-poster-width);
aspect-ratio: 2 / 3;
border-radius: 12px;
overflow: hidden;
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.1));
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.55);
align-self: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.hero__poster-fallback {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(
circle at 50% 40%,
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
transparent 60%
);
background-size: cover;
background-position: center;
mat-icon {
font-size: 56px;
width: 56px;
height: 56px;
color: var(--app-selection-color, #78adff);
opacity: 0.75;
}
}
.hero__body {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
min-width: 0;
}
.hero__title {
margin: 6px 0 4px;
font-size: 1.75rem;
font-weight: 600;
line-height: 1.15;
// Display-weight tracking: looser than -0.02em which crushes glyphs at
// this size (~28px). Matches Apple TV+ "Up Next" card scale.
letter-spacing: -0.015em;
color: var(--app-heading-color, #d8dce8);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
.hero--with-image & {
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
}
}
.hero__subtitle {
margin: 0;
font-size: 0.88rem;
font-weight: 500;
line-height: 1.45;
letter-spacing: 0.002em;
color: var(--app-body-color, #8b93a8);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.hero--with-image & {
color: rgba(255, 255, 255, 0.82);
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
}
}
.hero__cta {
margin-top: 16px;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px 10px 14px;
border-radius: 999px;
background: var(--app-selection-color, #78adff);
color: var(--app-selection-on-color, #071225);
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
letter-spacing: 0.005em;
// Explicit min-height anchors the text baseline regardless of font-smoothing
min-height: 36px;
box-sizing: border-box;
box-shadow: 0 4px 12px -4px
var(--app-selection-glow, rgba(120, 173, 255, 0.5));
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
}
}
// ── Loading skeleton ────────────────────────────────────────────────────────
// Geometry mirrors the real hero + rails so the layout doesn't shift when
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
// defined below.
%skeleton-shimmer {
background: linear-gradient(
90deg,
var(--app-widget-bg, #1e2330) 0%,
var(--app-widget-header-bg, #232840) 50%,
var(--app-widget-bg, #1e2330) 100%
);
background-size: 200% 100%;
animation: dashboard-skeleton-shimmer 1.6s linear infinite;
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
@keyframes dashboard-skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
// ── Hero ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-hero {
position: relative;
display: grid;
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
gap: 24px;
padding: 20px;
border-radius: 18px;
background: var(--app-widget-header-bg, #232840);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
}
.rails-page__skeleton-hero-poster {
@extend %skeleton-shimmer;
width: 160px;
aspect-ratio: 2 / 3;
border-radius: 12px;
align-self: center;
}
.rails-page__skeleton-hero-body {
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
min-width: 0;
}
.rails-page__skeleton-hero-title {
@extend %skeleton-shimmer;
width: 60%;
height: 28px;
border-radius: 6px;
}
.rails-page__skeleton-hero-title--short {
width: 35%;
}
.rails-page__skeleton-hero-subtitle {
@extend %skeleton-shimmer;
width: 45%;
height: 14px;
border-radius: 4px;
margin-top: 2px;
}
.rails-page__skeleton-hero-cta {
@extend %skeleton-shimmer;
width: 160px;
height: 36px;
border-radius: 999px;
margin-top: 14px;
}
// ── Rail ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0 0;
}
.rails-page__skeleton-header {
@extend %skeleton-shimmer;
width: 180px;
height: 22px;
border-radius: 6px;
}
.rails-page__skeleton-track {
display: flex;
gap: 14px;
padding: 4px 0 16px;
overflow: hidden;
}
.rails-page__skeleton-card {
@extend %skeleton-shimmer;
// Matches the real .rail__card width (--rail-card-width: 172px) and
// .rail__art aspect-ratio 2/3, so when cards arrive the geometry is
// identical and there's no layout shift.
flex: 0 0 172px;
width: 172px;
aspect-ratio: 2 / 3;
border-radius: 12px;
}
@media (prefers-reduced-motion: reduce) {
.rails-page__skeleton-hero-poster,
.rails-page__skeleton-hero-title,
.rails-page__skeleton-hero-subtitle,
.rails-page__skeleton-hero-cta,
.rails-page__skeleton-header,
.rails-page__skeleton-card {
animation: none;
background-position: 0 0;
}
}
// ── Responsive ──────────────────────────────────────────────────────────────
@media (max-width: 720px) {
:host {
padding: 16px 16px 32px;
}
.rails-page {
gap: 24px;
}
.hero {
--hero-poster-width: 120px;
grid-template-columns: var(--hero-poster-width) minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
// Skeleton hero mirrors real hero at this breakpoint.
.rails-page__skeleton-hero {
grid-template-columns: 120px minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
.rails-page__skeleton-hero-poster {
width: 120px;
}
.rails-page__skeleton-card {
flex: 0 0 140px;
width: 140px;
}
.hero__title {
font-size: 1.3rem;
}
}
@@ -0,0 +1,104 @@
import type { PlaylistMeta } from 'shared-interfaces';
import {
buildDashboardSourceActions,
resolveDashboardHeroArtwork,
} from './workspace-dashboard-rails.component';
describe('buildDashboardSourceActions', () => {
const basePlaylist = {
_id: 'playlist-1',
title: 'Playlist',
importDate: '2026-04-24T08:00:00.000Z',
autoRefresh: false,
} as PlaylistMeta;
const actionIds = (playlist: PlaylistMeta, canRefresh: boolean) =>
buildDashboardSourceActions(playlist, canRefresh).map(
(action) => action.id
);
it('exposes refresh, info, and remove for refreshable M3U sources', () => {
const playlist = {
...basePlaylist,
url: 'https://example.com/playlist.m3u',
} as PlaylistMeta;
expect(actionIds(playlist, true)).toEqual([
'refresh',
'playlist-info',
'remove',
]);
});
it('exposes refresh, info, account, and remove for refreshable Xtream sources', () => {
const playlist = {
...basePlaylist,
serverUrl: 'https://provider.example.test',
username: 'demo',
password: 'secret',
} as PlaylistMeta;
expect(actionIds(playlist, true)).toEqual([
'refresh',
'playlist-info',
'account-info',
'remove',
]);
});
it('exposes info and remove for Stalker sources', () => {
const playlist = {
...basePlaylist,
macAddress: '00:1A:79:00:00:01',
portalUrl: 'https://stalker.example.test',
} as PlaylistMeta;
expect(actionIds(playlist, false)).toEqual(['playlist-info', 'remove']);
});
});
describe('resolveDashboardHeroArtwork', () => {
const item = {
title: 'Broken Hero',
posterUrl: 'https://images.example.test/poster.jpg',
backdropUrl: 'https://images.example.test/backdrop.jpg',
};
it('uses the explicit backdrop when both hero images are available', () => {
expect(resolveDashboardHeroArtwork(item, {})).toMatchObject({
backdropUrl: item.backdropUrl,
backdropSource: 'backdrop',
hasBackdrop: true,
posterUrl: item.posterUrl,
});
});
it('falls back from a failed backdrop to the poster cover', () => {
expect(
resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
})
).toMatchObject({
backdropUrl: item.posterUrl,
backdropSource: 'poster',
hasBackdrop: false,
posterUrl: item.posterUrl,
});
});
it('uses generated fallback artwork when backdrop and poster both fail', () => {
const artwork = resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
[item.posterUrl]: true,
});
expect(artwork).toMatchObject({
backdropSource: 'fallback',
hasBackdrop: false,
posterUrl: undefined,
backdropUrl: undefined,
});
expect(artwork.fallbackBackdropBackground).toContain('linear-gradient');
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
});
});
@@ -0,0 +1,484 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
EmptyStateComponent,
PlaylistInfoComponent,
} from '@iptvnator/playlist/shared/ui';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/util';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspacePlaylistType,
} from '@iptvnator/workspace/shell/util';
import { DialogService } from 'components';
import { PlaylistActions } from 'm3u-state';
import { DatabaseService } from 'services';
import {
DashboardDataService,
DashboardFavoriteItem,
DashboardRecentlyAddedItem,
GlobalRecentItem,
} from 'workspace-dashboard-data-access';
import { DashboardRailComponent } from './dashboard-rail.component';
import type {
DashboardRailAction,
DashboardRailCard,
DashboardRailActionSelection,
} from './dashboard-rail.component';
import type { PlaylistMeta } from 'shared-interfaces';
// Cap dashboard rails at 20 items. Users get ~3× what's visible at once,
// the DOM stays cheap, and the "Manage all" link is one click away for the
// full list. Matches the single-rail density of Netflix / Apple TV+.
const RAIL_ITEM_LIMIT = 20;
// Six placeholder slots per skeleton rail — fills a typical viewport without
// taking the whole page. Mirrors the recently-added skeleton density.
const SKELETON_CARDS_PER_RAIL = [1, 2, 3, 4, 5, 6] as const;
const SKELETON_RAILS = [1, 2, 3] as const;
interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly icon: string;
readonly link: string[];
readonly posterUrl?: string;
readonly state?: Record<string, unknown>;
readonly subtitle: string;
readonly title: string;
}
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
export interface DashboardHeroArtworkInput {
readonly backdropUrl?: string | null;
readonly posterUrl?: string | null;
readonly title: string;
}
export interface DashboardHeroArtwork {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly posterUrl?: string;
}
export function resolveDashboardHeroArtwork(
item: DashboardHeroArtworkInput,
failedImages: Record<string, true>
): DashboardHeroArtwork {
const posterUrl =
item.posterUrl && !failedImages[item.posterUrl]
? item.posterUrl
: undefined;
const explicitBackdropUrl =
item.backdropUrl && !failedImages[item.backdropUrl]
? item.backdropUrl
: undefined;
const backdropUrl = explicitBackdropUrl ?? posterUrl;
const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl
? 'backdrop'
: posterUrl
? 'poster'
: 'fallback';
return {
backdropUrl,
backdropSource,
fallbackBackdropBackground: buildFallbackBackground(
item.title,
50,
15,
80,
5,
60
),
fallbackPosterBackground: buildFallbackBackground(
item.title,
40,
25,
50,
15,
40
),
hasBackdrop: backdropSource === 'backdrop',
posterUrl,
};
}
function buildFallbackBackground(
title: string,
saturationA: number,
lightnessA: number,
saturationB: number,
lightnessB: number,
hueOffset: number
): string {
const hue = calculateHue(title || 'placeholder');
const h2 = (hue + hueOffset) % 360;
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
function calculateHue(text: string): number {
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;
}
export type DashboardSourceActionId =
| 'refresh'
| 'playlist-info'
| 'account-info'
| 'remove';
export function buildDashboardSourceActions(
playlist: PlaylistMeta,
canRefresh: boolean
): DashboardRailAction[] {
const actions: DashboardRailAction[] = [];
if (canRefresh) {
actions.push({
id: 'refresh',
icon: 'sync',
labelKey: playlist.serverUrl
? 'HOME.PLAYLISTS.REFRESH_XTREAM'
: 'HOME.PLAYLISTS.REFRESH',
});
}
actions.push({
id: 'playlist-info',
icon: 'edit',
labelKey: 'HOME.PLAYLISTS.SHOW_DETAILS',
});
if (isXtreamAccountPlaylist(playlist)) {
actions.push({
id: 'account-info',
icon: 'person',
labelKey: 'WORKSPACE.SHELL.ACCOUNT_INFO',
});
}
actions.push({
id: 'remove',
icon: 'delete',
labelKey: 'HOME.PLAYLISTS.REMOVE_DIALOG.TITLE',
destructive: true,
separatorBefore: true,
});
return actions;
}
function isXtreamAccountPlaylist(
playlist: PlaylistMeta
): playlist is PlaylistMeta & {
serverUrl: string;
username: string;
password: string;
} {
return Boolean(
playlist.serverUrl && playlist.username && playlist.password
);
}
@Component({
selector: 'lib-workspace-dashboard-rails',
imports: [
DashboardRailComponent,
EmptyStateComponent,
MatButtonModule,
MatIcon,
RouterLink,
TranslatePipe,
],
templateUrl: './workspace-dashboard-rails.component.html',
styleUrl: './workspace-dashboard-rails.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.rails-page-host--empty]': 'ready() && !hasPlaylists()',
},
})
export class WorkspaceDashboardRailsComponent {
readonly data = inject(DashboardDataService);
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
readonly hasPlaylists = computed(() => this.data.playlists().length > 0);
readonly ready = this.data.dashboardReady;
readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount;
readonly isElectron = !!window.electron;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
readonly failedHeroImages = signal<Record<string, true>>({});
readonly hero = computed<DashboardHeroModel | null>(() => {
const item = this.data.globalRecentItems()[0] ?? null;
if (!item) {
return null;
}
const artwork = resolveDashboardHeroArtwork(
{
backdropUrl: item.backdrop_url,
posterUrl: item.poster_url,
title: item.title,
},
this.failedHeroImages()
);
return {
...artwork,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
subtitle: this.buildHeroSubtitle(item),
title: item.title,
};
});
readonly recentlyWatchedCards = computed<DashboardRailCard[]>(() =>
this.data
.globalRecentItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentCard(item))
);
readonly favoriteCards = computed<DashboardRailCard[]>(() =>
this.data
.globalFavoriteItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
readonly xtreamRecentlyAddedCards = computed<DashboardRailCard[]>(() =>
this.data
.xtreamRecentlyAddedItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toRecentlyAddedCard(item))
);
readonly sourceCards = computed<DashboardRailCard[]>(() =>
this.data.recentPlaylists().map((playlist) => ({
id: playlist._id,
title:
playlist.title ||
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE'),
subtitle: this.data.getPlaylistProvider(playlist),
icon: playlist.serverUrl
? 'cloud'
: playlist.macAddress
? 'cast'
: 'folder_open',
link: this.data.getPlaylistLink(playlist),
actions: buildDashboardSourceActions(
playlist,
this.playlistRefreshAction.canRefresh(playlist)
),
}))
);
constructor() {
// Re-entering the dashboard should pick up any DB-backed recent/favorite
// changes made while viewing details, including newly backfilled
// backdrops that do not change recency ordering.
void this.data.reloadGlobalRecentItems();
void this.data.reloadGlobalFavorites();
// Refresh when Xtream playlist count changes so a newly added provider
// populates the rail without a manual dashboard reload.
effect(() => {
if (this.xtreamPlaylistCount() === 0) {
return;
}
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
});
}
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.shellActions.openAddPlaylistDialog(type);
}
markHeroImageFailed(url: string): void {
this.failedHeroImages.update((state) =>
state[url] ? state : { ...state, [url]: true }
);
}
onSourceActionSelected(selection: DashboardRailActionSelection): void {
const playlist = this.data
.playlists()
.find((item) => item._id === selection.card.id);
if (!playlist) {
return;
}
switch (selection.action.id as DashboardSourceActionId) {
case 'refresh':
this.playlistRefreshAction.refresh(playlist);
break;
case 'playlist-info':
this.dialog.open(PlaylistInfoComponent, { data: playlist });
break;
case 'account-info':
this.openXtreamAccountInfo(playlist);
break;
case 'remove':
this.confirmRemovePlaylist(playlist);
break;
}
}
private buildHeroSubtitle(item: GlobalRecentItem): string {
const parts = [
item.playlist_name,
this.data.getRecentItemProviderLabel(item),
this.data.getRecentItemTypeLabel(item),
].filter((value): value is string => Boolean(value));
return parts.join(' · ');
}
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
return {
id: `recent-${item.id}-${item.playlist_id}-${item.viewed_at}`,
title: item.title,
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
};
}
private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard {
return {
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getGlobalFavoriteLink(item),
state: this.data.getGlobalFavoriteNavigationState(item),
};
}
private toRecentlyAddedCard(
item: DashboardRecentlyAddedItem
): DashboardRailCard {
const typeLabel = this.data.getRecentlyAddedItemTypeLabel(item);
const subtitleParts = [item.playlist_name, typeLabel].filter(
(value): value is string => Boolean(value)
);
return {
id: `added-${item.id}-${item.playlist_id}-${item.added_at}`,
title: item.title,
subtitle: subtitleParts.join(' · '),
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
link: this.data.getRecentlyAddedLink(item),
state: this.data.getRecentlyAddedNavigationState(item),
};
}
private typeIcon(type: 'live' | 'movie' | 'series'): string {
if (type === 'live') return 'live_tv';
if (type === 'movie') return 'movie';
return 'video_library';
}
private openXtreamAccountInfo(playlist: PlaylistMeta): void {
if (!isXtreamAccountPlaylist(playlist)) {
return;
}
const title =
playlist.title ||
playlist.filename ||
this.t('WORKSPACE.DASHBOARD.UNTITLED_SOURCE');
this.shellActions.openAccountInfo({
playlist: {
id: playlist._id,
name: title,
title,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
});
}
private confirmRemovePlaylist(playlist: PlaylistMeta): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: () => {
void this.removePlaylist(playlist);
},
});
}
private async removePlaylist(playlist: PlaylistMeta): Promise<void> {
const operationId = playlist.serverUrl
? this.databaseService.createOperationId('playlist-delete')
: undefined;
const deleted = await this.databaseService.deletePlaylist(
playlist._id,
operationId ? { operationId } : undefined
);
if (!deleted) {
return;
}
this.store.dispatch(
PlaylistActions.removePlaylist({ playlistId: playlist._id })
);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
undefined,
{ duration: 2000 }
);
}
private t(key: string): string {
return this.translate.instant(key);
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
@@ -0,0 +1,28 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/test-setup.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}