style: enhance account info component with improved loading states, error handling, and UI updates

This commit is contained in:
4gray committed 2026-04-04 13:35:48 +02:00
1 parent 8b5344ef26
commit a2e09dacdc
3 files changed
+863 -187

No files matched your search

@@ -1,114 +1,218 @@
<h2 mat-dialog-title>{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}</h2>
<mat-dialog-content class="mat-typography">
@if (accountInfo?.user_info?.message) {
<div class="welcome-message">
{{ accountInfo?.user_info?.message }}
</div>
}
<div class="info-grid">
<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.STATUS' | translate }}:
<span [class.active]="isActive">{{
accountInfo?.user_info?.status
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.USERNAME' | translate }}:
{{ accountInfo?.user_info?.username }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS' | translate }}:
{{ accountInfo?.user_info?.active_cons }}/{{
accountInfo?.user_info?.max_connections
}}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.CREATED' | translate }}:
{{ formattedCreatedDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.EXPIRES' | translate }}:
{{ formattedExpDate }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate }}:
<span [class.trial]="isTrial">{{
(isTrial
? 'XTREAM.ACCOUNT_INFO.YES'
: 'XTREAM.ACCOUNT_INFO.NO'
) | translate
}}</span>
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}:
<span class="formats">
{{
accountInfo?.user_info?.allowed_output_formats?.join(
', '
)
}}
</span>
</mat-list-item>
</mat-list>
<section mat-dialog-title class="account-dialog__hero">
<div class="account-dialog__hero-copy">
<div class="account-dialog__eyebrow">
<span class="account-dialog__eyebrow-mark"></span>
@if (accountInfo(); as info) {
{{ info.server_info?.server_protocol || 'Xtream' }}
@if (info.server_info?.timezone) {
<span class="account-dialog__eyebrow-divider"></span>
{{ info.server_info?.timezone }}
}
} @else {
Xtream
}
</div>
<div class="info-section">
<h3>{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}</h3>
<mat-list>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.LIVE_TV' | translate }}:
{{ liveStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.MOVIES' | translate }}:
{{ vodStreamsCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TV_SERIES' | translate }}:
{{ seriesCount }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.URL' | translate }}:
{{ accountInfo?.server_info?.url }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PROTOCOL' | translate }}:
{{ accountInfo?.server_info?.server_protocol }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.TIMEZONE' | translate }}:
{{ accountInfo?.server_info?.timezone }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.SERVER_TIME' | translate }}:
{{ accountInfo?.server_info?.time_now }}
</mat-list-item>
<mat-list-item>
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}:
<div class="ports">
<div class="account-dialog__headline-row">
<div>
<h2 class="account-dialog__title">
{{ 'XTREAM.ACCOUNT_INFO.TITLE' | translate }}
</h2>
<p class="account-dialog__subtitle">
{{ playlistLabel() }}
</p>
</div>
@if (accountInfo(); as info) {
<div class="account-dialog__status-cluster">
<span
class="account-dialog__status-pill"
[class.account-dialog__status-pill--active]="isActive()"
>
{{ info.user_info?.status || '-' }}
</span>
@if (isTrial()) {
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTP_PORT' | translate }}:
{{ accountInfo?.server_info?.port }}</span
class="account-dialog__status-pill account-dialog__status-pill--trial"
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.HTTPS_PORT' | translate }}:
{{ accountInfo?.server_info?.https_port }}</span
>
<span
>{{ 'XTREAM.ACCOUNT_INFO.RTMP_PORT' | translate }}:
{{ accountInfo?.server_info?.rtmp_port }}</span
>
</div>
</mat-list-item>
</mat-list>
{{
'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT' | translate
}}
</span>
}
</div>
}
</div>
@if (accountInfo()?.user_info?.message) {
<div class="account-dialog__message">
{{ accountInfo()?.user_info?.message }}
</div>
} @else if (loadState() === 'loading') {
<p class="account-dialog__loading-copy">
{{ 'LOADING' | translate }}...
</p>
}
</div>
<div class="account-dialog__hero-stats">
@if (loadState() === 'ready') {
@for (stat of heroStats(); track stat.labelKey) {
<article class="account-stat">
<div class="account-stat__icon">
<mat-icon>{{ stat.icon }}</mat-icon>
</div>
<div class="account-stat__label">
{{ stat.labelKey | translate }}
</div>
<div class="account-stat__value">
{{ stat.value }}
</div>
@if (stat.meter !== null) {
<div class="account-stat__meter">
<span [style.width.%]="stat.meter"></span>
</div>
}
</article>
}
} @else {
@for (item of skeletonStats; track item) {
<div class="account-skeleton account-skeleton--stat"></div>
}
}
</div>
</section>
<mat-dialog-content class="account-dialog__content">
@if (loadState() === 'error') {
<section class="account-dialog__error-state">
<div class="account-dialog__error-icon">
<mat-icon>signal_wifi_connected_no_internet_4</mat-icon>
</div>
<h3>
{{ 'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.TITLE' | translate }}
</h3>
<p>
{{
'PORTALS.ERROR_VIEW.PORTAL_UNAVAILABLE.DESCRIPTION'
| translate
}}
</p>
<button mat-flat-button color="primary" (click)="reload()">
{{ 'RETRY' | translate }}
</button>
</section>
} @else if (loadState() === 'loading') {
<div class="account-dialog__grid">
@for (panel of skeletonPanels; track panel) {
<section class="account-panel account-panel--loading">
<div class="account-skeleton account-skeleton--heading"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row"></div>
<div class="account-skeleton account-skeleton--row short"></div>
</section>
}
</div>
} @else if (accountInfo(); as info) {
<div class="account-dialog__grid">
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.USER_INFO' | translate }}
</p>
<h3>{{ info.user_info?.username || '-' }}</h3>
</div>
<dl class="account-detail-list">
@for (row of userDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
[class.account-detail-row__value--accent]="
row.tone === 'accent'
"
[class.account-detail-row__value--positive]="
row.tone === 'positive'
"
[class.account-detail-row__value--warning]="
row.tone === 'warning'
"
>
@if (row.translateValue) {
{{ row.value | translate }}
} @else {
{{ row.value }}
}
</dd>
</div>
}
</dl>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{ 'XTREAM.ACCOUNT_INFO.ALLOWED_FORMATS' | translate }}
</p>
<div class="account-chip-group">
@for (format of allowedFormats(); track format) {
<span class="account-chip">
{{ format }}
</span>
} @empty {
<span class="account-chip account-chip--muted">
-
</span>
}
</div>
</div>
</section>
<section class="account-panel">
<div class="account-panel__header">
<p class="account-panel__eyebrow">
{{ 'XTREAM.ACCOUNT_INFO.SERVER_INFO' | translate }}
</p>
<h3>{{ serverHost() }}</h3>
</div>
<dl class="account-detail-list">
@for (row of serverDetails(); track row.labelKey) {
<div class="account-detail-row">
<dt>{{ row.labelKey | translate }}</dt>
<dd
[class.account-detail-row__value--mono]="
row.mono
"
>
{{ row.value }}
</dd>
</div>
}
</dl>
<div class="account-chip-section">
<p class="account-chip-section__label">
{{ 'XTREAM.ACCOUNT_INFO.PORTS' | translate }}
</p>
<div class="account-chip-group">
@for (port of ports(); track port.labelKey) {
<span class="account-chip account-chip--mono">
{{ port.labelKey | translate }} {{ port.value }}
</span>
}
</div>
</div>
</section>
</div>
}
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close color="accent">
<mat-dialog-actions class="account-dialog__actions">
<button mat-stroked-button mat-dialog-close class="account-dialog__close">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
@@ -1,89 +1,477 @@
.account-info-card {
margin: 16px;
max-width: 1200px;
:host {
display: block;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.info-grid {
// ─── Hero header ────────────────────────────────────────────────────────────
.account-dialog__hero {
margin: 0;
padding: 20px 20px 18px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
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));
}
.info-section {
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
.account-dialog__hero-copy {
display: grid;
gap: 10px;
}
.account-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;
}
.account-dialog__eyebrow-mark {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--app-provider-xtream, var(--mat-sys-primary));
box-shadow: 0 0 0 4px color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 14%, transparent);
}
.account-dialog__eyebrow-divider {
width: 3px;
height: 3px;
border-radius: 999px;
background: currentColor;
opacity: 0.5;
}
.account-dialog__headline-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.account-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));
}
.account-dialog__subtitle {
margin: 4px 0 0;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
font-size: 0.88rem;
}
// ─── Status pills ───────────────────────────────────────────────────────────
.account-dialog__status-cluster {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.account-dialog__status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
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.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.account-dialog__status-pill--active {
border-color: color-mix(in srgb, #34d399 30%, transparent);
background: color-mix(in srgb, #34d399 12%, transparent);
color: #2db36f;
}
.account-dialog__status-pill--trial {
border-color: color-mix(in srgb, #fbbf24 28%, transparent);
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #d18b09;
}
// ─── Messages ───────────────────────────────────────────────────────────────
.account-dialog__message,
.account-dialog__loading-copy {
margin: 0;
max-width: 60ch;
color: var(--app-body-color, var(--mat-sys-on-surface));
}
.account-dialog__message {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container);
line-height: 1.5;
font-size: 0.88rem;
}
// ─── Hero stat cards ────────────────────────────────────────────────────────
.account-dialog__hero-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.account-stat {
display: grid;
gap: 6px;
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);
// Subtle inset highlight matching widget-shell pattern
outline: 1px solid rgba(255, 255, 255, 0.04);
outline-offset: -1px;
}
.account-stat__icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 12%, transparent);
color: var(--app-provider-xtream, var(--mat-sys-primary));
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.account-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;
}
.account-stat__value {
font-size: 1.3rem;
font-weight: 650;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.account-stat__meter {
position: relative;
overflow: hidden;
height: 4px;
border-radius: 999px;
background: var(--app-separator);
}
.account-stat__meter span {
display: block;
height: 100%;
border-radius: inherit;
background: var(--app-provider-xtream, var(--mat-sys-primary));
}
// ─── Content area ───────────────────────────────────────────────────────────
.account-dialog__content {
margin: 0;
padding: 20px;
background: var(--app-widget-bg, var(--mat-sys-surface));
}
.account-dialog__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
// ─── Detail panels ──────────────────────────────────────────────────────────
.account-panel {
display: grid;
gap: 14px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
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;
}
.info-section h3 {
margin-bottom: 16px;
padding-left: 16px;
.account-panel__header {
display: grid;
gap: 4px;
}
.account-panel__header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
border-bottom: 1px solid var(--mat-sys-outline-variant);
padding-bottom: 8px;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
mat-list-item {
height: auto !important;
margin-bottom: 8px;
padding: 8px 16px;
.account-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;
}
strong {
margin-right: 8px;
min-width: 140px;
display: inline-block;
// ─── Detail rows ────────────────────────────────────────────────────────────
.account-detail-list {
display: grid;
gap: 0;
margin: 0;
}
.active {
color: var(--mat-sys-primary);
.account-detail-row {
display: grid;
grid-template-columns: minmax(120px, 140px) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
padding: 10px 0;
border-bottom: 1px solid var(--app-separator);
}
.account-detail-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.account-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;
}
.account-detail-row dd {
margin: 0;
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.9rem;
font-weight: 500;
word-break: break-word;
}
.trial {
color: var(--mat-sys-error);
font-weight: 500;
.account-detail-row__value--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
font-size: 0.84rem;
}
.formats {
color: var(--mat-sys-tertiary);
.account-detail-row__value--accent {
color: var(--app-provider-xtream, var(--mat-sys-primary));
}
.ports {
.account-detail-row__value--positive {
color: #2db36f;
}
.account-detail-row__value--warning {
color: #d18b09;
}
// ─── Chip sections ──────────────────────────────────────────────────────────
.account-chip-section {
display: grid;
gap: 10px;
}
.account-chip-section__label {
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;
}
.account-chip-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
gap: 8px;
}
.ports span {
padding: 4px 8px;
background: var(--mat-sys-primary);
color: var(--mat-sys-on-primary);
border-radius: 4px;
font-size: 0.9em;
.account-chip {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 16%, transparent);
background: color-mix(in srgb, var(--app-provider-xtream, var(--mat-sys-primary)) 8%, transparent);
color: var(--app-body-color, var(--mat-sys-on-surface));
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.welcome-message {
border: 1px solid var(--mat-sys-outline-variant);
padding: 16px;
border-radius: 4px;
margin-bottom: 16px;
.account-chip--mono {
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
text-transform: none;
}
.account-chip--muted {
border-color: var(--app-separator);
background: var(--mat-sys-surface-container);
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
}
// ─── Error state ────────────────────────────────────────────────────────────
.account-dialog__error-state {
display: grid;
justify-items: center;
gap: 12px;
padding: 36px 20px;
border-radius: 16px;
border: 1px dashed var(--app-separator);
background: var(--app-widget-bg, var(--mat-sys-surface-container-low));
text-align: center;
font-style: italic;
}
@media (max-width: 768px) {
.info-grid {
.account-dialog__error-state h3,
.account-dialog__error-state p {
margin: 0;
}
.account-dialog__error-state p {
max-width: 52ch;
color: var(--app-muted-color, var(--mat-sys-on-surface-variant));
line-height: 1.5;
font-size: 0.88rem;
}
.account-dialog__error-icon {
width: 48px;
height: 48px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, var(--mat-sys-error) 12%, transparent);
color: var(--mat-sys-error);
}
// ─── Skeleton loading ───────────────────────────────────────────────────────
.account-panel--loading {
gap: 12px;
}
.account-skeleton {
position: relative;
overflow: hidden;
border-radius: 999px;
background: var(--app-separator);
}
.account-skeleton::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--app-heading-color, var(--mat-sys-on-surface)) 10%, transparent),
transparent
);
animation: account-dialog-shimmer 1.4s infinite;
}
.account-skeleton--stat {
min-height: 110px;
border-radius: 12px;
}
.account-skeleton--heading {
width: 54%;
height: 20px;
}
.account-skeleton--row {
width: 100%;
height: 16px;
}
.account-skeleton--row.short {
width: 62%;
}
// ─── Footer actions ─────────────────────────────────────────────────────────
.account-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));
}
.account-dialog__close {
min-width: 100px;
border-radius: 999px;
}
@keyframes account-dialog-shimmer {
100% {
transform: translateX(100%);
}
}
// ─── Responsive breakpoints ─────────────────────────────────────────────────
@media (max-width: 980px) {
.account-dialog__hero-stats,
.account-dialog__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.account-dialog__hero,
.account-dialog__content,
.account-dialog__actions {
padding-left: 16px;
padding-right: 16px;
}
.account-dialog__headline-row {
flex-direction: column;
align-items: flex-start;
}
.account-dialog__status-cluster {
justify-content: flex-start;
}
.account-dialog__hero-stats,
.account-dialog__grid {
grid-template-columns: 1fr;
}
.account-info-card {
margin: 8px;
.account-detail-row {
grid-template-columns: 1fr;
gap: 4px;
}
.ports {
flex-direction: column;
gap: 8px;
}
}
}
@@ -1,7 +1,13 @@
import { Component, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import {
XtreamAccountInfo,
@@ -10,11 +16,34 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
type AccountLoadState = 'loading' | 'ready' | 'error';
interface AccountStat {
icon: string;
labelKey: string;
value: string;
meter: number | null;
}
interface AccountDetailRow {
labelKey: string;
value: string;
mono?: boolean;
tone?: 'accent' | 'positive' | 'warning';
translateValue?: boolean;
}
interface AccountPort {
labelKey: string;
value: string;
}
@Component({
selector: 'app-account-info',
imports: [MatButton, MatDialogModule, MatListModule, TranslatePipe],
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
templateUrl: './account-info.component.html',
styleUrl: './account-info.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccountInfoComponent {
readonly data = inject<{
@@ -26,51 +55,206 @@ export class AccountInfoComponent {
private readonly xtreamStore = inject(XtreamStore);
private readonly logger = createLogger('XtreamAccountInfo');
accountInfo: XtreamAccountInfo;
formattedExpDate: string;
formattedCreatedDate: string;
vodStreamsCount: number;
liveStreamsCount: number;
seriesCount: number;
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
readonly loadState = signal<AccountLoadState>('loading');
readonly accountInfo = signal<XtreamAccountInfo | null>(null);
readonly skeletonStats = [1, 2, 3, 4];
readonly skeletonPanels = [1, 2];
readonly vodStreamsCount = this.data.vodStreamsCount;
readonly liveStreamsCount = this.data.liveStreamsCount;
readonly seriesCount = this.data.seriesCount;
readonly isActive = computed(
() => this.accountInfo()?.user_info?.status === 'Active'
);
readonly isTrial = computed(
() => this.accountInfo()?.user_info?.is_trial === '1'
);
readonly playlistLabel = computed(() => {
const playlist = this.currentPlaylist();
const info = this.accountInfo();
return (
playlist?.title ||
info?.server_info?.url ||
info?.user_info?.username ||
'-'
);
});
readonly serverHost = computed(
() => this.accountInfo()?.server_info?.url || '-'
);
readonly activeConnections = computed(() =>
this.parseNumber(this.accountInfo()?.user_info?.active_cons)
);
readonly maxConnections = computed(() =>
this.parseNumber(this.accountInfo()?.user_info?.max_connections)
);
readonly connectionUsagePercent = computed(() => {
const maxConnections = this.maxConnections();
if (maxConnections <= 0) {
return 0;
}
return Math.min(
100,
Math.round((this.activeConnections() / maxConnections) * 100)
);
});
readonly activeConnectionsLabel = computed(
() => `${this.activeConnections()}/${Math.max(this.maxConnections(), 0)}`
);
readonly formattedExpDate = computed(() =>
this.formatUnixDate(this.accountInfo()?.user_info?.exp_date)
);
readonly formattedCreatedDate = computed(() =>
this.formatUnixDate(this.accountInfo()?.user_info?.created_at)
);
readonly allowedFormats = computed(
() => this.accountInfo()?.user_info?.allowed_output_formats ?? []
);
readonly ports = computed<AccountPort[]>(() => {
const serverInfo = this.accountInfo()?.server_info;
return [
{
labelKey: 'XTREAM.ACCOUNT_INFO.HTTP_PORT',
value: serverInfo?.port || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.HTTPS_PORT',
value: serverInfo?.https_port || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.RTMP_PORT',
value: serverInfo?.rtmp_port || '-',
},
];
});
readonly heroStats = computed<AccountStat[]>(() => [
{
icon: 'bolt',
labelKey: 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS',
value: this.activeConnectionsLabel(),
meter: this.connectionUsagePercent(),
},
{
icon: 'live_tv',
labelKey: 'XTREAM.ACCOUNT_INFO.LIVE_TV',
value: String(this.liveStreamsCount),
meter: null,
},
{
icon: 'movie',
labelKey: 'XTREAM.ACCOUNT_INFO.MOVIES',
value: String(this.vodStreamsCount),
meter: null,
},
{
icon: 'tv',
labelKey: 'XTREAM.ACCOUNT_INFO.TV_SERIES',
value: String(this.seriesCount),
meter: null,
},
]);
readonly userDetails = computed<AccountDetailRow[]>(() => [
{
labelKey: 'XTREAM.ACCOUNT_INFO.STATUS',
value: this.accountInfo()?.user_info?.status || '-',
tone: this.isActive() ? 'positive' : undefined,
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.USERNAME',
value: this.accountInfo()?.user_info?.username || '-',
mono: true,
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.ACTIVE_CONNECTIONS',
value: this.activeConnectionsLabel(),
tone: 'accent',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.CREATED',
value: this.formattedCreatedDate(),
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.EXPIRES',
value: this.formattedExpDate(),
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.TRIAL_ACCOUNT',
value: this.isTrial()
? 'XTREAM.ACCOUNT_INFO.YES'
: 'XTREAM.ACCOUNT_INFO.NO',
translateValue: true,
tone: this.isTrial() ? 'warning' : undefined,
},
]);
readonly serverDetails = computed<AccountDetailRow[]>(() => [
{
labelKey: 'XTREAM.ACCOUNT_INFO.URL',
value: this.accountInfo()?.server_info?.url || '-',
mono: true,
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.PROTOCOL',
value: this.accountInfo()?.server_info?.server_protocol || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.TIMEZONE',
value: this.accountInfo()?.server_info?.timezone || '-',
},
{
labelKey: 'XTREAM.ACCOUNT_INFO.SERVER_TIME',
value: this.accountInfo()?.server_info?.time_now || '-',
mono: true,
},
]);
constructor() {
this.setAccountInfo();
this.vodStreamsCount = this.data.vodStreamsCount;
this.liveStreamsCount = this.data.liveStreamsCount;
this.seriesCount = this.data.seriesCount;
void this.reload();
}
async setAccountInfo() {
async reload(): Promise<void> {
const playlist = this.currentPlaylist();
if (!playlist) return;
if (!playlist) {
this.loadState.set('error');
this.accountInfo.set(null);
return;
}
this.loadState.set('loading');
try {
this.accountInfo = await this.xtreamApiService.getAccountInfo({
const accountInfo = await this.xtreamApiService.getAccountInfo({
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
});
if (this.accountInfo) {
this.formattedExpDate = new Date(
parseInt(this.accountInfo.user_info.exp_date) * 1000
).toLocaleDateString();
this.formattedCreatedDate = new Date(
parseInt(this.accountInfo.user_info.created_at) * 1000
).toLocaleDateString();
}
this.accountInfo.set(accountInfo);
this.loadState.set('ready');
} catch (error) {
this.logger.error('Failed to fetch account info', error);
this.accountInfo.set(null);
this.loadState.set('error');
}
}
get isActive(): boolean {
return this.accountInfo?.user_info?.status === 'Active';
private formatUnixDate(timestamp?: string): string {
const value = Number.parseInt(timestamp ?? '', 10);
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
return new Date(value * 1000).toLocaleDateString();
}
get isTrial(): boolean {
return this.accountInfo?.user_info?.is_trial === '1';
private parseNumber(value?: string): number {
const parsed = Number.parseInt(value ?? '', 10);
return Number.isFinite(parsed) ? parsed : 0;
}
}