refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 20:28:31 +01:00
1 parent 9834c9fc0d
commit 0ad1eeb785
193 files changed
+1908 -3370

No files matched your search

+12
View File
@@ -9,15 +9,27 @@ export * from './lib/content-hero/content-hero.component';
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
export * from './lib/epg-list/epg-list-item/epg-list-item.component';
export * from './lib/epg-list/epg-list.component';
export * from './lib/epg-progress-panel/epg-progress-panel.component';
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/filter-sort-menu/filter-sort-menu.component';
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/info-overlay/info-overlay.component';
export * from './lib/multi-epg/multi-epg-container.component';
export * from './lib/multi-epg/overlay-ref.token';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/progress-capsule/progress-capsule.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/playlist-item/playlist-item.component';
export * from './lib/recent-playlists/recent-playlists.component';
export * from './lib/season-container/season-container.component';
export * from './lib/stalker-favorites-button/stalker-favorites-button.component';
export * from './lib/stalker-series-view/stalker-series-view.component';
export * from './lib/video-player/sidebar/sidebar.component';
export * from './lib/video-player/toolbar/toolbar.component';
export * from './lib/vjs-player/vjs-player.component';
export * from './lib/vod-details/vod-details.component';
export * from './lib/watched-badge/watched-badge.component';
export * from './lib/web-player-view/web-player-view.component';
export * from './lib/resizable/resizable.directive';
@@ -0,0 +1,115 @@
@if (isVisible()) {
<div class="epg-progress-panel">
<div class="panel-header">
<span class="header-title">
<mat-icon>live_tv</mat-icon>
{{ 'EPG.IMPORT_PROGRESS' | translate }}
</span>
@if (imports().length > 1) {
<button
mat-icon-button
class="dismiss-all-btn"
(click)="dismissAll()"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
<div class="imports-list">
@for (item of activeImports; track item.url) {
<div class="import-item" [class]="getStatusClass(item.status)">
<div class="item-header">
<div class="status-icon-wrapper">
<mat-icon
[class.spinning]="item.status === 'loading'"
>
{{ getStatusIcon(item.status) }}
</mat-icon>
</div>
<span class="item-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="'BUTTONS.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (item.status === 'loading') {
<mat-progress-bar mode="indeterminate" />
}
<div class="item-stats">
@if (item.stats) {
<span
class="stat-badge"
matTooltip="{{
'EPG.TOTAL_CHANNELS' | translate
}}"
>
<mat-icon>tv</mat-icon>
{{ item.stats.totalChannels }}
</span>
<span
class="stat-badge"
matTooltip="{{
'EPG.TOTAL_PROGRAMS' | translate
}}"
>
<mat-icon>event</mat-icon>
{{ item.stats.totalPrograms }}
</span>
} @else if (item.error) {
<span class="error-text">{{ item.error }}</span>
} @else if (item.status === 'loading') {
<span>{{ 'EPG.LOADING' | translate }}...</span>
}
</div>
</div>
}
@if (queuedImports.length > 0) {
<div class="queue-section">
<div class="queue-header">
<mat-icon>queue</mat-icon>
<span
>{{ 'EPG.QUEUED' | translate }} ({{
queuedImports.length
}})</span
>
</div>
<div class="queue-list">
@for (
item of queuedImports;
track item.url;
let i = $index
) {
<div class="queue-item">
<span class="queue-position">{{ i + 1 }}</span>
<span class="queue-url" [title]="item.url">
{{ getDisplayUrl(item.url) }}
</span>
<button
mat-icon-button
class="dismiss-btn"
(click)="dismiss(item.url)"
[attr.aria-label]="
'BUTTONS.CLOSE' | translate
"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
</div>
</div>
}
</div>
</div>
}
@@ -0,0 +1,403 @@
:host {
--epg-panel-bg: rgba(18, 22, 28, 0.92);
--epg-panel-bg-solid: #12161c;
--epg-panel-border: rgba(56, 189, 248, 0.15);
--epg-panel-border-glow: rgba(56, 189, 248, 0.25);
--epg-header-bg: rgba(56, 189, 248, 0.08);
--epg-header-border: rgba(56, 189, 248, 0.12);
--epg-text-primary: rgba(255, 255, 255, 0.95);
--epg-text-secondary: rgba(255, 255, 255, 0.6);
--epg-text-tertiary: rgba(255, 255, 255, 0.4);
--epg-accent: #38bdf8;
--epg-accent-glow: rgba(56, 189, 248, 0.4);
--epg-success: #22c55e;
--epg-success-glow: rgba(34, 197, 94, 0.3);
--epg-error: #ef4444;
--epg-error-glow: rgba(239, 68, 68, 0.3);
--epg-queued: #a78bfa;
--epg-queued-glow: rgba(167, 139, 250, 0.3);
--epg-item-border: rgba(255, 255, 255, 0.06);
--epg-item-hover: rgba(56, 189, 248, 0.05);
--epg-progress-track: rgba(255, 255, 255, 0.08);
--epg-scrollbar-thumb: rgba(56, 189, 248, 0.3);
--epg-scrollbar-track: rgba(255, 255, 255, 0.05);
--epg-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
--epg-icon-bg: rgba(56, 189, 248, 0.12);
--epg-queue-bg: rgba(167, 139, 250, 0.06);
--epg-queue-border: rgba(167, 139, 250, 0.15);
}
:host-context(.light-theme),
:host-context([data-theme='light']),
:host-context(.mat-app-background.light) {
--epg-panel-bg: rgba(255, 255, 255, 0.95);
--epg-panel-bg-solid: #ffffff;
--epg-panel-border: rgba(15, 118, 110, 0.2);
--epg-panel-border-glow: rgba(15, 118, 110, 0.15);
--epg-header-bg: rgba(15, 118, 110, 0.06);
--epg-header-border: rgba(15, 118, 110, 0.1);
--epg-text-primary: rgba(15, 23, 42, 0.95);
--epg-text-secondary: rgba(15, 23, 42, 0.6);
--epg-text-tertiary: rgba(15, 23, 42, 0.4);
--epg-accent: #0f766e;
--epg-accent-glow: rgba(15, 118, 110, 0.25);
--epg-success: #16a34a;
--epg-success-glow: rgba(22, 163, 74, 0.2);
--epg-error: #dc2626;
--epg-error-glow: rgba(220, 38, 38, 0.2);
--epg-queued: #7c3aed;
--epg-queued-glow: rgba(124, 58, 237, 0.2);
--epg-item-border: rgba(15, 23, 42, 0.08);
--epg-item-hover: rgba(15, 118, 110, 0.04);
--epg-progress-track: rgba(15, 23, 42, 0.08);
--epg-scrollbar-thumb: rgba(15, 118, 110, 0.3);
--epg-scrollbar-track: rgba(15, 23, 42, 0.05);
--epg-shadow:
0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.08);
--epg-icon-bg: rgba(15, 118, 110, 0.1);
--epg-queue-bg: rgba(124, 58, 237, 0.05);
--epg-queue-border: rgba(124, 58, 237, 0.12);
}
.epg-progress-panel {
position: fixed;
bottom: 20px;
left: 20px;
width: 340px;
max-width: calc(100vw - 40px);
background: var(--epg-panel-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 16px;
box-shadow: var(--epg-shadow);
z-index: 1000;
overflow: hidden;
border: 1px solid var(--epg-panel-border);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
var(--epg-panel-border-glow) 20%,
var(--epg-accent-glow) 50%,
var(--epg-panel-border-glow) 80%,
transparent 100%
);
}
animation: slideInFromLeft 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideInFromLeft {
from {
opacity: 0;
transform: translateX(-24px) scale(0.96);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--epg-header-bg);
border-bottom: 1px solid var(--epg-header-border);
position: relative;
.header-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.02em;
color: var(--epg-text-primary);
text-transform: uppercase;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--epg-accent);
filter: drop-shadow(0 0 6px var(--epg-accent-glow));
animation: pulse-glow 2s ease-in-out infinite;
}
}
.dismiss-all-btn {
width: 28px;
height: 28px;
padding: 2px;
line-height: 28px;
border-radius: 8px;
transition: all 0.2s ease;
color: var(--epg-text-tertiary);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
@keyframes pulse-glow {
0%,
100% {
filter: drop-shadow(0 0 4px var(--epg-accent-glow));
}
50% {
filter: drop-shadow(0 0 10px var(--epg-accent-glow));
}
}
.imports-list {
max-height: 360px;
overflow-y: auto;
padding: 4px 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--epg-scrollbar-track);
border-radius: 3px;
margin: 4px 0;
}
&::-webkit-scrollbar-thumb {
background: var(--epg-scrollbar-thumb);
border-radius: 3px;
&:hover {
background: var(--epg-accent);
}
}
}
.import-item {
padding: 6px 12px;
border-radius: 10px;
transition: all 0.2s ease;
position: relative;
margin: 0 6px;
&:not(:last-child) {
margin-bottom: 2px;
}
&:hover {
background: var(--epg-item-hover);
}
.item-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
.status-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--epg-icon-bg);
flex-shrink: 0;
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
transition: all 0.3s ease;
}
.item-url {
flex: 1;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--epg-text-secondary);
font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace;
letter-spacing: -0.02em;
}
.dismiss-btn {
width: 26px;
height: 26px;
padding: 2px;
line-height: 26px;
border-radius: 6px;
opacity: 0;
transition: all 0.2s ease;
color: var(--epg-text-tertiary);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
&:hover .dismiss-btn {
opacity: 1;
}
&.status-loading {
.status-icon-wrapper mat-icon {
color: var(--epg-accent);
}
}
&.status-complete {
.status-icon-wrapper mat-icon {
color: var(--epg-success);
filter: drop-shadow(0 0 6px var(--epg-success-glow));
}
}
&.status-error {
.status-icon-wrapper mat-icon {
color: var(--epg-error);
filter: drop-shadow(0 0 6px var(--epg-error-glow));
}
}
}
.spinning {
animation: spin 1.2s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.item-stats {
display: flex;
align-items: center;
gap: 8px;
padding-left: 42px;
min-height: 24px;
.stat-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
background: var(--epg-item-border);
color: var(--epg-text-secondary);
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
}
.error-text {
font-size: 12px;
color: var(--epg-error);
}
}
.queue-section {
margin: 8px 6px 6px;
padding: 10px 12px;
border-radius: 12px;
background: var(--epg-queue-bg);
border: 1px solid var(--epg-queue-border);
}
.queue-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
color: var(--epg-text-primary);
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--epg-queued);
}
}
.queue-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.queue-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 8px;
color: var(--epg-text-secondary);
.queue-position {
width: 18px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: var(--epg-queued);
}
.queue-url {
flex: 1;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace;
}
.dismiss-btn {
width: 24px;
height: 24px;
padding: 2px;
line-height: 24px;
color: var(--epg-text-tertiary);
}
}
@media (max-width: 700px) {
.epg-progress-panel {
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
max-width: none;
}
}
@@ -0,0 +1,75 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressBar } from '@angular/material/progress-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgImportProgress, EpgProgressService } from 'services';
@Component({
selector: 'app-epg-progress-panel',
standalone: true,
imports: [
MatButtonModule,
MatIconModule,
MatTooltip,
MatProgressBar,
TranslatePipe,
],
templateUrl: './epg-progress-panel.component.html',
styleUrl: './epg-progress-panel.component.scss',
})
export class EpgProgressPanelComponent {
private readonly epgProgress = inject(EpgProgressService);
readonly imports = this.epgProgress.imports;
readonly isVisible = this.epgProgress.isVisible;
readonly queuedCount = this.epgProgress.queuedCount;
readonly activeCount = this.epgProgress.activeCount;
get activeImports() {
return this.imports().filter((item) => item.status !== 'queued');
}
get queuedImports() {
return this.imports()
.filter((item) => item.status === 'queued')
.sort(
(a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)
);
}
getStatusIcon(status: EpgImportProgress['status']): string {
switch (status) {
case 'queued':
return 'schedule';
case 'loading':
return 'sync';
case 'complete':
return 'check_circle';
case 'error':
return 'error';
}
}
getStatusClass(status: EpgImportProgress['status']): string {
return `status-${status}`;
}
getDisplayUrl(url: string): string {
try {
const urlObject = new URL(url);
return urlObject.hostname + urlObject.pathname.split('/').pop();
} catch {
return url.length > 40 ? `${url.substring(0, 40)}...` : url;
}
}
dismiss(url: string): void {
this.epgProgress.dismiss(url);
}
dismissAll(): void {
this.epgProgress.dismissAll();
}
}
@@ -0,0 +1,66 @@
<section
class="external-playback-dock"
[class.external-playback-dock--compact]="compact()"
[class.external-playback-dock--error]="session().status === 'error'"
>
<div class="external-playback-dock__artwork">
@if (session().thumbnail) {
<img
[src]="session().thumbnail!"
[alt]="session().title"
loading="lazy"
/>
} @else {
<div class="external-playback-dock__placeholder">
<mat-icon>play_circle</mat-icon>
</div>
}
</div>
<div class="external-playback-dock__meta">
<div class="external-playback-dock__topline">
<span class="external-playback-dock__chip">{{ playerLabel() }}</span>
@if (!compact()) {
<span class="external-playback-dock__caption">
External player
</span>
}
</div>
<div class="external-playback-dock__title">{{ session().title }}</div>
<div class="external-playback-dock__status">
@if (showSpinner()) {
<mat-spinner diameter="14" strokeWidth="3"></mat-spinner>
} @else {
<mat-icon>{{
session().status === 'error' ? 'error' : 'play_arrow'
}}</mat-icon>
}
<span>{{ statusLabel() }}</span>
</div>
</div>
<div class="external-playback-dock__actions">
@if (showCloseButton()) {
<button
type="button"
mat-stroked-button
class="external-playback-dock__button"
(click)="closeClicked.emit()"
>
<mat-icon>stop_circle</mat-icon>
@if (!compact()) {
<span>Close player</span>
}
</button>
}
<button
type="button"
mat-icon-button
class="external-playback-dock__dismiss"
(click)="dismissClicked.emit()"
aria-label="Dismiss now playing panel"
>
<mat-icon>close</mat-icon>
</button>
</div>
</section>
@@ -0,0 +1,169 @@
: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: 12px 14px;
border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 60%, transparent);
border-radius: 20px;
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);
backdrop-filter: blur(18px);
}
.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;
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);
img,
.external-playback-dock__placeholder {
width: 100%;
height: 100%;
}
img {
object-fit: cover;
display: block;
}
}
.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__topline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.external-playback-dock__chip {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-primary-container) 82%, transparent);
color: var(--mat-sys-on-primary-container);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
}
.external-playback-dock__caption {
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
text-transform: uppercase;
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);
}
.external-playback-dock__status {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.78rem;
mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
mat-spinner {
flex-shrink: 0;
}
}
.external-playback-dock__actions {
display: flex;
align-items: center;
gap: 6px;
}
.external-playback-dock__button {
border-radius: 999px;
}
.external-playback-dock__dismiss {
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 720px) {
.external-playback-dock {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.external-playback-dock:not(.external-playback-dock--compact) {
.external-playback-dock__button span {
display: none;
}
}
}
@@ -0,0 +1,52 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ExternalPlayerSession } from 'shared-interfaces';
import { ExternalPlaybackDockComponent } from './external-playback-dock.component';
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],
}).compileComponents();
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('Opening in MPV...');
expect(text).toContain('Close player');
});
it('emits close and dismiss actions', () => {
const closeSpy = jest.fn();
const dismissSpy = jest.fn();
component.closeClicked.subscribe(closeSpy);
component.dismissClicked.subscribe(dismissSpy);
const buttons = fixture.debugElement.queryAll(By.css('button'));
buttons[0].nativeElement.click();
buttons[1].nativeElement.click();
expect(closeSpy).toHaveBeenCalled();
expect(dismissSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ExternalPlayerSession } from 'shared-interfaces';
@Component({
selector: 'app-external-playback-dock',
imports: [MatButtonModule, MatIcon, MatProgressSpinnerModule],
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 dismissClicked = output<void>();
readonly playerLabel = computed(() => this.session().player.toUpperCase());
readonly statusLabel = computed(() => {
const session = this.session();
const player = this.playerLabel();
switch (session.status) {
case 'launching':
return `Opening in ${player}...`;
case 'opened':
return `Opened in ${player}`;
case 'playing':
return `Playing in ${player}`;
case 'error':
return session.error || `${player} failed to launch`;
default:
return `${player} closed`;
}
});
readonly showSpinner = computed(
() => this.session().status === 'launching'
);
readonly showCloseButton = computed(
() => this.session().canClose && this.session().status !== 'error'
);
}
@@ -0,0 +1,77 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
@Component({
template: `
<h2 mat-dialog-title>{{ 'INFORMATION' | translate }}</h2>
<mat-dialog-content class="mat-typography">
<div class="centered">
<mat-icon class="icon">live_tv</mat-icon>
<div>
The video is playing in external player window.<br />
Please make sure that mpv/vlc player is correctly installed
on your system.<br />
See
<a
[routerLink]
style="cursor: pointer"
(click)="
openUrl(
'https://github.com/4gray/iptvnator/wiki/What-is-mpv-video-player-and-how-to-install-it-on-different-operating-systems%3F'
)
"
>installation instructions</a
>
for more details.
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions style="justify-content: space-between;">
<div>
<mat-checkbox (change)="setVisibility($event.checked)"
>Don't show anymore</mat-checkbox
>
</div>
<button mat-button mat-dialog-close cdkFocusInitial color="accent">
{{ 'CLOSE' | translate }}
</button>
</mat-dialog-actions>
`,
styles: [
`
.centered {
text-align: center;
line-height: 24px;
margin: 20px;
.icon {
font-size: 64px;
height: 64px;
width: 64px;
}
}
`,
],
imports: [
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatIconModule,
RouterLink,
TranslateModule,
],
})
export class ExternalPlayerInfoDialogComponent {
openUrl(url: string) {
window.open(url, '_blank');
}
setVisibility(value: boolean) {
localStorage.setItem('hideExternalInfoDialog', value.toString());
}
}
@@ -0,0 +1,140 @@
<mat-menu #filterSortMenu="matMenu" class="filter-sort-menu" xPosition="before">
<section class="filter-section">
<div class="menu-section-header">
<mat-icon class="section-icon">filter_list</mat-icon>
<span>{{ 'HOME.FILTER_BY_TYPE' | translate }}</span>
</div>
@for (type of playlistTypes; track type.id) {
<button
mat-menu-item
(click)="togglePlaylistType(type); $event.stopPropagation()"
[class.active-option]="type.checked"
>
<mat-icon>{{ type.icon }}</mat-icon>
<span class="menu-item-text">{{
type.translationKey | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="type.checked"
>check</mat-icon
>
</button>
}
</section>
<section class="sort-section">
<div class="menu-section-header">
<mat-icon class="section-icon">sort</mat-icon>
<span>{{ 'HOME.SORT_BY' | translate }}</span>
</div>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_ASC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="isSortActive(SortBy.NAME, SortOrder.ASC)"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.NAME, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.NAME, SortOrder.DESC)"
>
<mat-icon>sort_by_alpha</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NAME_DESC' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.NAME, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.DESC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.NEWEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.DESC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.DATE_ADDED, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>
<mat-icon>date_range</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.OLDEST' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.DATE_ADDED, SortOrder.ASC)
"
>check</mat-icon
>
</button>
<button
mat-menu-item
(click)="
setSortOptions(SortBy.CUSTOM, SortOrder.ASC);
$event.stopPropagation()
"
[class.active-option]="isSortActive(SortBy.CUSTOM, SortOrder.ASC)"
>
<mat-icon>drag_indicator</mat-icon>
<span class="menu-item-text">{{
'HOME.SORT_OPTIONS.CUSTOM_ORDER' | translate
}}</span>
<mat-icon
class="check-icon"
[class.check-visible]="
isSortActive(SortBy.CUSTOM, SortOrder.ASC)
"
>check</mat-icon
>
</button>
</section>
<div class="menu-footer">
<button mat-button (click)="closeMenu()">
{{ 'CLOSE' | translate }}
</button>
</div>
</mat-menu>
@@ -0,0 +1,87 @@
.filter-section,
.sort-section {
padding: 4px 0;
}
.menu-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px 4px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
pointer-events: none;
.section-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: rgba(139, 92, 246, 0.6);
}
}
::ng-deep .filter-sort-menu {
.mat-mdc-menu-item {
display: flex !important;
align-items: center !important;
gap: 12px !important;
min-height: 40px !important;
padding: 0 16px !important;
.mat-icon:first-child {
margin-right: 0 !important;
flex-shrink: 0;
}
.menu-item-text {
flex: 1;
text-align: left;
}
.check-icon {
margin-left: auto !important;
margin-right: 0 !important;
flex-shrink: 0;
opacity: 0;
transform: scale(0.8);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.check-visible {
opacity: 1;
transform: scale(1);
}
}
}
}
.active-option {
background-color: rgba(139, 92, 246, 0.12) !important;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
background: rgba(139, 92, 246, 0.8);
border-radius: 0 2px 2px 0;
}
}
.check-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
color: rgba(139, 92, 246, 1) !important;
}
.menu-footer {
padding: 4px 8px 4px;
display: flex;
justify-content: center;
}
@@ -0,0 +1,118 @@
import {
Component,
effect,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { FilterActions, selectActiveTypeFilters } from 'm3u-state';
import { SortBy, SortOrder, SortService } from 'services';
@Component({
selector: 'app-filter-sort-menu',
templateUrl: './filter-sort-menu.component.html',
styleUrls: ['./filter-sort-menu.component.scss'],
imports: [MatButtonModule, MatIconModule, MatMenuModule, TranslateModule],
})
export class FilterSortMenuComponent {
private readonly store = inject(Store);
private readonly sortService = inject(SortService);
readonly menu = viewChild.required<MatMenu>('filterSortMenu');
readonly menuTrigger = input<MatMenuTrigger>();
readonly filterChanged = output<void>();
playlistTypes = [
{
id: 'm3u',
icon: 'playlist_play',
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
checked: true,
},
{
id: 'xtream',
icon: 'cloud',
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
checked: true,
},
{
id: 'stalker',
icon: 'router',
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
checked: true,
},
];
private readonly selectedTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
readonly SortBy = SortBy;
readonly SortOrder = SortOrder;
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{
requireSync: true,
}
);
constructor() {
effect(() => {
if (this.selectedTypeFilters) {
this.playlistTypes = this.playlistTypes.map((type) => {
type.checked = this.selectedTypeFilters().includes(type.id);
return type;
});
}
});
}
togglePlaylistType(type: {
id: string;
icon: string;
translationKey: string;
checked: boolean;
}) {
const currentlySelectedCount = this.playlistTypes.filter(
(item) => item.checked
).length;
if (type.checked && currentlySelectedCount === 1) {
return;
}
type.checked = !type.checked;
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters: this.playlistTypes
.filter((item) => item.checked)
.map((item) => item.id),
})
);
this.filterChanged.emit();
}
setSortOptions(by: SortBy, order: SortOrder): void {
this.sortService.setSortOptions({ by, order });
}
isSortActive(by: SortBy, order: SortOrder): boolean {
const options = this.currentSortOptions();
return options?.by === by && options?.order === order;
}
get activeFiltersCount(): number {
return this.playlistTypes.filter((type) => !type.checked).length;
}
closeMenu(): void {
this.menuTrigger()?.closeMenu();
}
}
@@ -0,0 +1,40 @@
@if (hasPlayback()) {
<section class="player-shell" aria-label="Inline player">
<header class="player-shell__header">
<div class="player-shell__eyebrow">Now Playing</div>
<div class="player-shell__title-row">
<h3 class="player-shell__title" [matTooltip]="title()">
{{ title() }}
</h3>
<div class="player-shell__actions">
<button
mat-button
type="button"
[cdkCopyToClipboard]="streamUrl()"
[matTooltip]="streamUrl()"
(click)="onCopied()"
>
<mat-icon>content_copy</mat-icon>
Copy Stream URL
</button>
<button
mat-stroked-button
type="button"
(click)="onClose()"
>
<mat-icon>close</mat-icon>
Close Player
</button>
</div>
</div>
</header>
<div class="player-shell__viewport">
<app-web-player-view
[streamUrl]="streamUrl()"
[startTime]="startTime()"
(timeUpdate)="onTimeUpdate($event)"
/>
</div>
</section>
}
@@ -0,0 +1,106 @@
:host {
display: block;
width: 100%;
}
.player-shell {
display: grid;
gap: 14px;
padding: 18px;
margin-top: 8px;
border-radius: 18px;
border: 1px solid rgba(20, 20, 20, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 250, 245, 0.92)),
rgba(255, 250, 245, 0.9);
box-shadow:
0 20px 56px rgba(25, 20, 20, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(18px);
}
.player-shell__header {
display: grid;
gap: 8px;
}
.player-shell__eyebrow {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(26, 26, 26, 0.58);
}
.player-shell__title-row {
display: flex;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
}
.player-shell__title {
margin: 0;
font-size: clamp(1.05rem, 1vw + 0.9rem, 1.4rem);
font-weight: 700;
line-height: 1.2;
color: #161616;
}
.player-shell__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.player-shell__viewport {
overflow: hidden;
min-height: 320px;
max-height: min(70vh, 720px);
aspect-ratio: 16 / 9;
border-radius: 14px;
background: #000;
}
.player-shell__viewport > app-web-player-view {
display: block;
width: 100%;
height: 100%;
}
:host-context(.dark-theme) .player-shell {
border-color: rgba(255, 255, 255, 0.12);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent),
rgba(12, 12, 12, 0.72);
box-shadow:
0 24px 80px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
:host-context(.dark-theme) .player-shell__eyebrow {
color: rgba(255, 255, 255, 0.6);
}
:host-context(.dark-theme) .player-shell__title {
color: #fff;
}
@media (max-width: 900px) {
.player-shell {
padding: 14px;
}
.player-shell__title-row {
flex-direction: column;
}
.player-shell__actions {
justify-content: flex-start;
}
.player-shell__viewport {
min-height: 220px;
}
}
@@ -0,0 +1,63 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import {
PlayerContentInfo,
ResolvedPortalPlayback,
} from 'shared-interfaces';
import { WebPlayerViewComponent } from '../web-player-view/web-player-view.component';
@Component({
selector: 'app-portal-inline-player',
templateUrl: './portal-inline-player.component.html',
styleUrl: './portal-inline-player.component.scss',
imports: [
ClipboardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
WebPlayerViewComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'portal-inline-player',
'[attr.data-has-player]': 'hasPlayback()',
},
})
export class PortalInlinePlayerComponent {
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly title = computed(() => this.playback()?.title ?? '');
readonly streamUrl = computed(() => this.playback()?.streamUrl ?? '');
readonly startTime = computed(() => this.playback()?.startTime ?? 0);
readonly contentInfo = computed<PlayerContentInfo | undefined>(
() => this.playback()?.contentInfo
);
readonly hasPlayback = computed(() => !!this.playback()?.streamUrl);
readonly closed = output<void>();
readonly timeUpdate = output<{
currentTime: number;
duration: number;
}>();
readonly streamUrlCopied = output<void>();
onClose(): void {
this.closed.emit();
}
onTimeUpdate(event: { currentTime: number; duration: number }): void {
this.timeUpdate.emit(event);
}
onCopied(): void {
this.streamUrlCopied.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,322 @@
<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) {
<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 (!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 (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,708 @@
.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%;
}
// 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,396 @@
import { KeyValuePipe } from '@angular/common';
import {
Component,
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 {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('SeasonContainer');
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);
}
}
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);
}
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 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,12 @@
@let isFavorite = isFavorite$ | async;
@if (isFavorite === false) {
<button class="favorite-btn" (click)="addToFavorites()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button class="favorite-btn favorite-btn--active" (click)="removeFromFavorites()">
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
@@ -0,0 +1,32 @@
.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;
}
}
@@ -0,0 +1,67 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from 'services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { matchesFavoriteById } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-favorites-button',
templateUrl: './stalker-favorites-button.component.html',
styleUrls: ['./stalker-favorites-button.component.scss'],
imports: [AsyncPipe, MatIconModule, TranslateModule],
})
export class FavoritesButtonComponent {
private playlistService = inject(PlaylistsService);
private stalkerStore = inject(StalkerStore);
readonly itemId = input.required<string>();
readonly item = input.required<any>();
private readonly favoritesChanged$ = new BehaviorSubject<void>(undefined);
readonly isFavorite$ = this.favoritesChanged$.pipe(
switchMap(() =>
this.playlistService.getPortalFavorites(
this.stalkerStore.currentPlaylist()?._id
)
),
map((favorites) =>
favorites.some((favorite) =>
matchesFavoriteById(favorite, this.itemId())
)
)
);
removeFromFavorites() {
this.stalkerStore.removeFromFavorites(this.itemId(), () => {
this.favoritesChanged$.next();
});
}
addToFavorites() {
const item = this.item();
this.stalkerStore.addToFavorites(
{
...item,
title: item.info?.name ?? item.name ?? item.o_name,
cover: item.info?.movie_image ?? item.cover,
series_id: item.id,
added_at: new Date().toISOString(),
category_id: 'series',
},
() => {
this.favoritesChanged$.next();
}
);
}
forceRefresh() {
// Exposed for callers that need manual refresh after external updates.
if (this.stalkerStore.currentPlaylist()?._id) {
this.favoritesChanged$.next();
}
}
}
@@ -0,0 +1,92 @@
@if (displayItem(); as serial) {
<app-content-hero
[title]="serial.info.name"
[description]="serial.info.description"
[posterUrl]="serial.info.movie_image"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Rating -->
<ng-container hero-tags>
@if (serial.info.releasedate) {
<span class="details__tag">{{
serial.info.releasedate
}}</span>
}
@if (serial.info.genre) {
<span class="details__tag">{{
serial.info.genre
}}</span>
}
@if (serial.info.rating_imdb) {
<span class="details__tag details__tag--rating">
⭐ {{ serial.info.rating_imdb }}
</span>
}
@if (serial.info.rating_kinopoisk) {
<span class="details__tag">
KP {{ serial.info.rating_kinopoisk }}
</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (serial.info.actors) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.ACTORS' | translate
}}</span>
<span class="value">{{ serial.info.actors }}</span>
</div>
}
@if (serial.info.director) {
<div class="details__meta-item">
<span class="label">{{
'XTREAM.DIRECTOR' | translate
}}</span>
<span class="value">{{ serial.info.director }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<app-favorites-button
[itemId]="serial.id"
[item]="serial"
/>
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="handleInlineTimeUpdate($event)"
(closed)="closeInlinePlayer()"
(streamUrlCopied)="showCopyNotification()"
/>
}
<!-- Seasons & Episodes -->
@if (mappedSeasons()) {
<app-season-container
[seasons]="mappedSeasons()"
[seriesId]="toSeriesId(serial.id)"
[playlistId]="stalkerStore.currentPlaylist()?._id ?? ''"
[seriesTitle]="serial.info?.name"
[playbackPositions]="episodePlaybackPositions()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
(seasonContainer?.selectedSeason &&
isCurrentSeasonLoading(seasonContainer.selectedSeason))
"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
#seasonContainer
/>
}
</app-content-hero>
}
@@ -0,0 +1,675 @@
import {
Component,
OnDestroy,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
import { SeasonContainerComponent } from '../season-container/season-container.component';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from 'shared-interfaces';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
} from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
isVodSeriesItem,
mapRegularSeriesEpisodes,
mapRegularSeriesSeasons,
mapVodSeriesEpisodes,
mapVodSeriesSeasonsToVm,
StalkerMappedEpisode,
StalkerSeriesSeasonVm,
VodSeriesSeasonVm,
normalizeStalkerVodDetailsItem,
StalkerSelectedVodItem,
StalkerStore,
StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { DownloadsService } from 'services';
import { SeasonContainerPlaybackToggleRequest } from '../season-container/season-container.component';
/**
* Component for displaying series/episodes for Stalker portal content.
* Supports three modes:
* 1. Regular series (type=series): Fetches seasons from API via serialSeasonsResource
* 2. VOD with embedded series (vclub): Uses the series array from the vodWithSeries input
* 3. VOD series (Ministra is_series=1): Fetches seasons/episodes using movie_id and season_id
*/
@Component({
selector: 'app-stalker-series-view',
templateUrl: './stalker-series-view.component.html',
styleUrls: ['../styles/detail-view.scss'],
imports: [
FavoritesButtonComponent,
ContentHeroComponent,
PortalInlinePlayerComponent,
TranslatePipe,
SeasonContainerComponent,
],
})
export class StalkerSeriesViewComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly backClicked = output<void>();
private readonly logger = createLogger('StalkerSeriesView');
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
/**
* Optional input for VOD items with embedded series array (vclub mode)
* When provided, uses this instead of fetching seasons from API
*/
readonly vodWithSeries = input<StalkerVodSource | null>(null);
readonly selectedItem = this.stalkerStore.selectedItem;
/**
* Track VOD series seasons with their loaded episodes
*/
readonly vodSeriesSeasons = signal<VodSeriesSeasonVm[]>([]);
/**
* Indicates if this is a VOD series item (Ministra is_series=1)
* Note: is_series can be true, 1, or "1" depending on the source
*/
readonly isVodSeries = computed(() => {
return (
this.stalkerStore.selectedContentType() === 'vod' &&
isVodSeriesItem(this.displayItem())
);
});
/**
* Loading state for VOD series seasons
*/
readonly isVodSeriesSeasonsLoading =
this.stalkerStore.isVodSeriesSeasonsLoading;
/**
* Loading state for regular series seasons
*/
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
constructor() {
// Effect to load VOD series seasons when a VOD series item is selected
effect(() => {
if (this.isVodSeries()) {
// Get seasons from the resource
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
this.vodSeriesSeasons.set(mapVodSeriesSeasonsToVm(seasons));
} else {
this.vodSeriesSeasons.set([]);
}
});
// Effect to load playback positions for Stalker series
effect(() => {
const item = this.displayItem();
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
if (!isNaN(normalizedSeriesId)) {
void this.loadSeriesPositions(
playlist._id,
normalizedSeriesId
);
}
} else {
this.episodePlaybackPositions.set(new Map());
}
});
effect(() => {
const session = this.externalPlayback.activeSession();
const item = this.displayItem();
const playlistId = this.stalkerStore.currentPlaylist()?._id;
const seriesId = item ? this.toSeriesId(item.id) : 0;
if (
!session?.contentInfo ||
!playlistId ||
!seriesId ||
session.contentInfo.contentType !== 'episode' ||
session.contentInfo.playlistId !== playlistId ||
session.contentInfo.seriesXtreamId !== seriesId
) {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'launching') {
this.openingEpisodeId.set(session.contentInfo.contentXtreamId);
this.activeEpisodeId.set(null);
return;
}
if (session.status === 'opened' || session.status === 'playing') {
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(session.contentInfo.contentXtreamId);
return;
}
this.openingEpisodeId.set(null);
this.activeEpisodeId.set(null);
});
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const playlistId =
this.stalkerStore.currentPlaylist()?._id;
const item = this.displayItem();
const seriesId = item ? this.toSeriesId(item.id) : 0;
if (
data.contentType !== 'episode' ||
data.playlistId !== playlistId ||
data.seriesXtreamId !== seriesId
) {
return;
}
this.updateEpisodePlaybackPosition(data);
}
);
}
}
/**
* For VOD with embedded series, we create a single "season" with the episodes
* For regular series, we use the API-fetched seasons
*/
readonly regularSeasons = computed<StalkerSeriesSeasonVm[]>(() =>
mapRegularSeriesSeasons(
this.vodWithSeries(),
this.stalkerStore.getSerialSeasonsResource()
)
);
/**
* Get the item to display details for (either vodWithSeries or selectedItem from store)
*/
readonly displayItem = computed<StalkerSelectedVodItem | null>(() => {
const item = this.vodWithSeries() || this.selectedItem();
return item ? normalizeStalkerVodDetailsItem(item) : null;
});
/**
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
*/
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
() => {
if (this.isVodSeries()) {
return mapVodSeriesEpisodes(
this.vodSeriesSeasons(),
this.displayItem()?.info?.movie_image
);
}
return mapRegularSeriesEpisodes(
this.regularSeasons(),
this.displayItem()?.info?.movie_image
);
}
);
/**
* Handles season selection from the container.
* For VOD Series, triggers lazy loading of episodes.
*/
onSeasonSelected(seasonKey: string) {
if (!this.isVodSeries()) return;
const seasons = this.vodSeriesSeasons();
const season = seasons.find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
if (season && season.episodes.length === 0) {
this.loadEpisodesForSeason(season);
}
}
/**
* Loads episodes for a specific VOD season
*/
async loadEpisodesForSeason(season: VodSeriesSeasonVm) {
// Set loading state in local signal
const seasons = this.vodSeriesSeasons();
const index = seasons.findIndex((s) => s.id === season.id);
if (index === -1) return;
const updatedSeasons = [...seasons];
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
this.vodSeriesSeasons.set(updatedSeasons);
try {
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
season.video_id,
season.id
);
// Update with loaded episodes
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
episodes: episodes,
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
} catch (error) {
this.logger.error('Failed to load episodes', error);
const newSeasons = [...this.vodSeriesSeasons()];
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
if (newIndex !== -1) {
newSeasons[newIndex] = {
...newSeasons[newIndex],
isLoading: false,
};
this.vodSeriesSeasons.set(newSeasons);
}
}
}
/**
* Determines if the current selected season is loading
*/
isCurrentSeasonLoading(seasonKey: string): boolean {
if (!this.isVodSeries()) return false;
const season = this.vodSeriesSeasons().find(
(s) => getVodSeriesSeasonKey(s) === seasonKey
);
return season?.isLoading ?? false;
}
/**
* Handles episode click from the container
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
const mappedEpisode = episode as StalkerMappedEpisode;
if (mappedEpisode.custom_sid === 'vod-series') {
// It's a VOD series episode (is_series=1 mode)
// Use originalId for playback URL, but use generated id for tracking
this.playVodSeriesEpisode({
originalId: mappedEpisode.originalId, // For constructing playback URL
trackingId: episode.id, // The generated unique ID for position tracking
name: episode.title,
series_number: episode.episode_num,
});
} else {
// Regular series or vclub mode (VOD with embedded series array)
// Use originalCmd for playback, episode.id for tracking
const trackingId = Number(episode.id);
this.playEpisodeClicked(
episode.episode_num,
mappedEpisode.originalCmd,
trackingId
);
}
}
/**
* Play episode - handles regular series and vclub mode
*/
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
const item = this.displayItem();
if (!item) return;
this.logger.debug('playEpisodeClicked', {
episodeNum,
cmd,
trackingId,
seriesId: item.id,
});
const position = trackingId
? this.episodePlaybackPositions().get(trackingId)
: undefined;
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
item.info.name,
item.info.movie_image,
episodeNum,
trackingId,
startTime
);
}
/**
* Play VOD series episode
*/
playVodSeriesEpisode(episode: {
originalId?: string;
trackingId: string;
name: string;
series_number: number;
}) {
const item = this.displayItem();
if (!item) return;
// Use originalId for playback URL (this is what the Stalker API expects)
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
// Use trackingId (generated unique ID) for playback position tracking
const trackingId = Number(episode.trackingId);
this.logger.debug('playVodSeriesEpisode', {
originalId: episode.originalId,
trackingId,
seriesId: item.id,
episodeName,
});
const position = this.episodePlaybackPositions().get(trackingId);
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image,
episode.series_number,
trackingId,
startTime
);
}
goBack() {
this.closeInlinePlayer();
this.backClicked.emit();
this.stalkerStore.clearSelectedItem();
}
toSeriesId(id: string | number): number {
const raw = String(id ?? '').trim();
if (!raw) return 0;
const primary = raw.includes(':') ? raw.split(':')[0] : raw;
const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
}
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) return;
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return;
this.lastSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.updateEpisodePlaybackPosition(position);
}
showCopyNotification(): void {
this.snackBar.open(
this.translateService.instant('PORTALS.STREAM_URL_COPIED'),
null,
{
duration: 2000,
}
);
}
private async startPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<void> {
try {
const playback = await this.stalkerStore.resolveVodPlayback(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error &&
error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, null, {
duration: 3000,
});
}
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
async handlePlaybackToggleRequested(
request: SeasonContainerPlaybackToggleRequest
): Promise<void> {
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (!playlistId) {
return;
}
if (request.nextPosition) {
await this.playbackPositions.savePlaybackPosition(
playlistId,
request.nextPosition
);
this.updateEpisodePlaybackPosition(request.nextPosition);
return;
}
await this.playbackPositions.clearPlaybackPosition(
playlistId,
request.contentXtreamId,
'episode'
);
this.removeEpisodePlaybackPosition(request.contentXtreamId);
}
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
if (!playlist || !playlist.portalUrl || !playlist.macAddress || !item) {
return;
}
const customSid = (episode as { custom_sid?: string }).custom_sid;
const cmd =
customSid === 'vod-series'
? `/media/file_${(episode as { originalId?: string }).originalId}.mpg`
: ((episode as { originalCmd?: string }).originalCmd ?? '');
let url: string;
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd,
episode.episode_num
);
if (!url) {
this.logger.error('Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
this.logger.error('Error resolving Stalker stream URL', error);
return;
}
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
const seasonNum = Number(episode.season || 1);
const episodeNum = episode.episode_num || 1;
const seriesTitle = item.info?.name || this.displayItem()?.info?.name || 'Series';
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
2,
'0'
)}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: this.getEpisodeDownloadId(episode),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.toSeriesId(item.id),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
private async loadSeriesPositions(
playlistId: string,
seriesXtreamId: number
): Promise<void> {
const positions = await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
positions.forEach((position) => {
positionsMap.set(position.contentXtreamId, position);
});
this.episodePlaybackPositions.set(positionsMap);
}
private updateEpisodePlaybackPosition(position: PlaybackPositionData): void {
const updated = new Map(this.episodePlaybackPositions());
updated.set(position.contentXtreamId, position);
this.episodePlaybackPositions.set(updated);
}
private removeEpisodePlaybackPosition(contentXtreamId: number): void {
const updated = new Map(this.episodePlaybackPositions());
updated.delete(contentXtreamId);
this.episodePlaybackPositions.set(updated);
}
private getEpisodeInfo(
episode: XtreamSerieEpisode
): { movie_image?: string } | undefined {
if (!episode.info || Array.isArray(episode.info)) {
return undefined;
}
return episode.info as { movie_image?: string };
}
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 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,164 @@
@let meta = normalizedMeta();
<!-- Hero Section with Backdrop -->
<app-content-hero
[title]="meta.title"
[description]="meta.description"
[posterUrl]="meta.posterUrl"
[backdropUrl]="meta.backdropUrl"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Duration, Rating -->
<ng-container hero-tags>
@if (meta.year) {
<span class="details__tag">{{ meta.year }}</span>
}
@if (meta.genre) {
<span class="details__tag">{{ meta.genre }}</span>
}
@if (meta.duration) {
<span class="details__tag">{{ meta.duration }}</span>
}
@if (meta.ratingImdb) {
<span class="details__tag details__tag--rating">
⭐ {{ meta.ratingImdb }}
</span>
}
@if (meta.country) {
<span class="details__tag">{{ meta.country }}</span>
}
</ng-container>
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (meta.actors) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ meta.actors }}</span>
</div>
}
@if (meta.director) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
<span class="value">{{ meta.director }}</span>
</div>
}
@if (meta.ratingKinopoisk) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.KINOPOISK_RATING' | translate }}</span>
<span class="value">{{ meta.ratingKinopoisk }}</span>
</div>
}
</ng-container>
<!-- Action Buttons -->
<ng-container hero-actions>
<!-- Resume/Play buttons -->
@if (hasPlaybackPosition()) {
<button
class="play-btn play-btn--resume"
(click)="onPrimaryAction()"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.RESUME' | translate }} {{ formattedPosition() }}
}
</span>
</button>
@if (!isExternalStopAction()) {
<button class="play-btn play-btn--restart" (click)="onPlay()">
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
</button>
}
} @else {
<button
class="play-btn"
(click)="onPrimaryAction()"
[disabled]="isExternalLaunchPending()"
[class.play-btn--state-launching]="externalPrimaryButtonState() === 'launching'"
[class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'"
>
<span class="play-btn__icon-shell" aria-hidden="true">
<mat-icon class="play-btn__icon">{{ externalPrimaryIcon() }}</mat-icon>
</span>
<span class="play-btn__label">
@if (externalPrimaryLabel(); as label) {
{{ label }}
} @else {
{{ 'XTREAM.PLAY' | translate }}
}
</span>
</button>
}
<!-- Favorites button -->
@if (!isFavorite()) {
<button class="favorite-btn" (click)="toggleFavorite()">
<mat-icon>add</mat-icon>
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
</button>
} @else {
<button
class="favorite-btn favorite-btn--active"
(click)="toggleFavorite()"
>
<mat-icon>check</mat-icon>
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
</button>
}
<!-- Download buttons (Electron only) -->
@if (isElectron()) {
@if (isDownloaded()) {
<button class="download-btn download-btn--completed" (click)="playFromLocal()">
<mat-icon>play_circle</mat-icon>
{{ 'DOWNLOADS.PLAY_LOCAL' | translate }}
</button>
} @else if (isDownloading()) {
<button class="download-btn download-btn--downloading" disabled>
<mat-icon>downloading</mat-icon>
{{ 'DOWNLOADS.STATUS.DOWNLOADING' | translate }}
</button>
} @else {
<button class="download-btn" (click)="onDownload()">
<mat-icon>download</mat-icon>
{{ 'DOWNLOADS.DOWNLOAD' | translate }}
</button>
}
}
</ng-container>
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playback]="playback"
(timeUpdate)="onInlineTimeUpdate($event)"
(closed)="closeInlinePlayback()"
(streamUrlCopied)="onStreamUrlCopied()"
/>
}
<!-- YouTube Trailer (Xtream only) -->
@if (meta.youtubeTrailer) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="'https://www.youtube.com/embed/' + meta.youtubeTrailer | safe"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
</div>
}
</app-content-hero>
@@ -0,0 +1,312 @@
import {
Component,
computed,
inject,
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { SafePipe } from '@iptvnator/pipes';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import {
ExternalPlayerSession,
ResolvedPortalPlayback,
VodDetailsItem,
getVodNumericId,
normalizeVodDetails,
} from 'shared-interfaces';
import { DownloadsService } from 'services';
import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component';
/**
* Unified VOD details component for both Xtream and Stalker portals.
*
* Uses discriminated union (VodDetailsItem) for type-safe handling.
* All actions are emitted as outputs - parent components handle
* store-specific operations (play, favorites, downloads).
*
* @example
* ```html
* <app-vod-details
* [item]="vodItem"
* [isFavorite]="isFavorite()"
* [playbackPosition]="position()"
* (playClicked)="onPlay($event)"
* (resumeClicked)="onResume($event)"
* (favoriteToggled)="onToggleFavorite($event)"
* />
* ```
*/
@Component({
selector: 'app-vod-details',
templateUrl: './vod-details.component.html',
styleUrls: ['../styles/detail-view.scss'],
imports: [
ContentHeroComponent,
MatIcon,
PortalInlinePlayerComponent,
SafePipe,
TranslatePipe,
],
})
export class VodDetailsComponent {
// ============ Inputs ============
/** VOD item with discriminated union type */
readonly item = input.required<VodDetailsItem>();
/** Whether this item is in favorites (managed by parent) */
readonly isFavorite = input<boolean>(false);
/** Playback position in seconds for resume feature (managed by parent) */
readonly playbackPosition = input<number | null>(null);
/** Inline playback payload for embedded players (managed by parent) */
readonly inlinePlayback = input<ResolvedPortalPlayback | null>(null);
/** Active external playback session for launch state */
readonly externalPlayback = input<ExternalPlayerSession | null>(null);
// ============ Outputs ============
/** Emitted when play button is clicked */
readonly playClicked = output<VodDetailsItem>();
/** Emitted when resume button is clicked (includes position) */
readonly resumeClicked = output<{ item: VodDetailsItem; positionSeconds: number }>();
/** Emitted when favorite toggle is clicked */
readonly favoriteToggled = output<{ item: VodDetailsItem; isFavorite: boolean }>();
/** Emitted when back button is clicked */
readonly backClicked = output<void>();
/** Emitted when download is requested (parent handles URL construction) */
readonly downloadRequested = output<VodDetailsItem>();
/** Emitted when inline playback position changes */
readonly inlineTimeUpdated = output<{
currentTime: number;
duration: number;
}>();
/** Emitted when the inline player should be closed */
readonly inlinePlaybackClosed = output<void>();
/** Emitted when the stream url is copied */
readonly streamUrlCopied = output<void>();
// ============ Services ============
private readonly downloadsService = inject(DownloadsService);
private readonly externalPlaybackActions = inject(PORTAL_EXTERNAL_PLAYBACK);
// ============ Computed State ============
/** Whether running in Electron (downloads available) */
readonly isElectron = computed(() => this.downloadsService.isAvailable());
/** Normalized metadata for display */
readonly normalizedMeta = computed(() => {
return normalizeVodDetails(this.item());
});
/** Whether there's a playback position to resume from */
readonly hasPlaybackPosition = computed(() => {
const pos = this.playbackPosition();
return pos !== null && pos > 0;
});
/** Formatted playback position (e.g., "12:34" or "1:23:45") */
readonly formattedPosition = computed(() => {
const pos = this.playbackPosition();
if (!pos || pos <= 0) return '';
const hours = Math.floor(pos / 3600);
const minutes = Math.floor((pos % 3600) / 60);
const seconds = Math.floor(pos % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
});
/** Whether VOD is already downloaded */
readonly isDownloaded = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloaded(vodId, item.playlistId, 'vod');
});
/** Whether VOD is currently downloading */
readonly isDownloading = computed(() => {
const item = this.item();
// Access signal to create reactive dependency
this.downloadsService.downloads();
const vodId = getVodNumericId(item);
return this.downloadsService.isDownloading(vodId, item.playlistId, 'vod');
});
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback();
const item = this.item();
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
) {
return null;
}
const contentInfo = session.contentInfo;
if (
contentInfo.playlistId !== item.playlistId ||
contentInfo.contentType !== 'vod' ||
contentInfo.contentXtreamId !== getVodNumericId(item)
) {
return null;
}
return session;
});
readonly externalPrimaryLabel = computed(() => {
const session = this.matchedExternalPlayback();
if (!session) {
return null;
}
const player = session.player.toUpperCase();
switch (session.status) {
case 'launching':
return `Opening in ${player}...`;
case 'opened':
case 'playing':
return `Stop ${player}`;
default:
return null;
}
});
readonly externalPrimaryIcon = computed(() => {
const session = this.matchedExternalPlayback();
switch (session?.status) {
case 'launching':
return 'hourglass_top';
case 'opened':
case 'playing':
return 'stop_circle';
default:
return 'play_arrow';
}
});
readonly isExternalLaunchPending = computed(
() => this.matchedExternalPlayback()?.status === 'launching'
);
readonly isExternalStopAction = computed(() => {
const status = this.matchedExternalPlayback()?.status;
return status === 'opened' || status === 'playing';
});
readonly externalPrimaryButtonState = computed(() => {
if (this.isExternalLaunchPending()) {
return 'launching';
}
return this.isExternalStopAction() ? 'stop' : 'idle';
});
// ============ Actions ============
/** Handle play button click */
onPlay(): void {
this.playClicked.emit(this.item());
}
onPrimaryAction(): void {
if (this.isExternalStopAction()) {
void this.stopExternalPlayback();
return;
}
if (this.hasPlaybackPosition()) {
this.onResume();
return;
}
this.onPlay();
}
/** Handle resume button click */
onResume(): void {
const pos = this.playbackPosition();
if (pos && pos > 0) {
this.resumeClicked.emit({
item: this.item(),
positionSeconds: pos,
});
}
}
/** Handle favorite toggle - emits the desired new state */
toggleFavorite(): void {
this.favoriteToggled.emit({
item: this.item(),
isFavorite: !this.isFavorite(),
});
}
/** Handle back navigation - emit event for parent to handle */
goBack(): void {
this.backClicked.emit();
}
/** Handle download request */
onDownload(): void {
this.downloadRequested.emit(this.item());
}
onInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
this.inlineTimeUpdated.emit(event);
}
closeInlinePlayback(): void {
this.inlinePlaybackClosed.emit();
}
onStreamUrlCopied(): void {
this.streamUrlCopied.emit();
}
async stopExternalPlayback(): Promise<void> {
await this.externalPlaybackActions.closeSession(
this.matchedExternalPlayback()
);
}
/** Play from local downloaded file */
async playFromLocal(): Promise<void> {
const item = this.item();
const vodId = getVodNumericId(item);
const filePath = this.downloadsService.getDownloadedFilePath(
vodId,
item.playlistId,
'vod'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
@@ -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,27 @@
@if (player === 'videojs') {
<app-vjs-player
[options]="vjsOptions"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else if (player === 'html5') {
<app-html-video-player
[channel]="$any(channel)"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else if (player === 'artplayer') {
<app-art-player
[channel]="$any(channel)"
[volume]="1"
[showCaptions]="false"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
} @else {
<app-vjs-player
[options]="vjsOptions"
[startTime]="startTime()"
(timeUpdate)="timeUpdate.emit($event)"
/>
}
@@ -0,0 +1,11 @@
:host {
display: block;
height: 100%;
}
app-art-player,
app-vjs-player,
app-html-video-player {
display: block;
height: 100%;
}
@@ -0,0 +1,72 @@
import {
Component,
EventEmitter,
Output,
Signal,
ViewEncapsulation,
effect,
inject,
input,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { StorageMap } from '@ngx-pwa/local-storage';
import { getExtensionFromUrl } from 'm3u-utils';
import { Settings, STORE_KEY, VideoPlayer } from 'shared-interfaces';
import { ArtPlayerComponent } from '../art-player/art-player.component';
import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component';
import { VjsPlayerComponent } from '../vjs-player/vjs-player.component';
@Component({
selector: 'app-web-player-view',
templateUrl: './web-player-view.component.html',
styleUrls: ['./web-player-view.component.scss'],
imports: [ArtPlayerComponent, HtmlVideoPlayerComponent, VjsPlayerComponent],
encapsulation: ViewEncapsulation.None,
})
export class WebPlayerViewComponent {
storage = inject(StorageMap);
streamUrl = input.required<string>();
startTime = input<number>(0);
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
settings = toSignal(
this.storage.get(STORE_KEY.Settings)
) as Signal<Settings>;
channel!: { url: string };
player!: VideoPlayer;
vjsOptions!: { sources: { src: string; type: string }[] };
constructor() {
effect(() => {
this.player = this.settings()?.player ?? VideoPlayer.VideoJs;
this.setChannel(this.streamUrl());
this.setVjsOptions(this.streamUrl());
});
}
setVjsOptions(streamUrl: string) {
const extension = getExtensionFromUrl(streamUrl);
const mimeType =
extension === 'm3u' || extension === 'm3u8'
? 'application/x-mpegURL'
: extension === 'ts'
? 'video/mp2t'
: 'video/mp4';
this.vjsOptions = {
sources: [{ src: streamUrl, type: mimeType }],
};
}
setChannel(streamUrl: string) {
this.channel = {
url: streamUrl,
};
}
}