refactor: update nx monorepo structure

This commit is contained in:
4gray committed 2026-03-09 23:24:34 +01:00
1 parent aa739f43e0
commit 7ac2fd66b8
175 files changed
+3118 -1076

No files matched your search

-24
View File
@@ -1,35 +1,11 @@
export * from './lib/add-playlist-menu/add-playlist-menu.component';
export * from './lib/art-player/art-player.component';
export * from './lib/audio-player/audio-player.component';
export * from './lib/channel-list-container/channel-list-container.component';
export * from './lib/channel-list-container/channel-list-item/channel-list-item.component';
export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
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';
@@ -1,43 +0,0 @@
<mat-menu #addPlaylistMenu="matMenu">
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.FILE_UPLOAD' | translate"
(click)="onPlaylistTypeClick('file')"
>
<mat-icon>attach_file</mat-icon>
<span>{{ 'HOME.TABS.FILE_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.URL_UPLOAD' | translate"
(click)="onPlaylistTypeClick('url')"
>
<mat-icon>link</mat-icon>
<span>{{ 'HOME.TABS.URL_UPLOAD' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.TEXT_IMPORT' | translate"
(click)="onPlaylistTypeClick('text')"
>
<mat-icon>text_format</mat-icon>
<span>{{ 'HOME.TABS.TEXT_IMPORT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.XTREME_IMPORT' | translate"
(click)="onPlaylistTypeClick('xtream')"
>
<mat-icon>video_library</mat-icon>
<span>{{ 'HOME.TABS.XTREME_IMPORT' | translate }}</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'HOME.TABS.STALKER_PORTAL_IMPORT' | translate"
(click)="onPlaylistTypeClick('stalker')"
>
<mat-icon>dashboard</mat-icon>
<span>{{ 'HOME.TABS.STALKER_PORTAL_IMPORT' | translate }}</span>
</button>
</mat-menu>
@@ -1,22 +0,0 @@
import { Component, output, viewChild } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenu, MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
@Component({
selector: 'app-add-playlist-menu',
templateUrl: './add-playlist-menu.component.html',
imports: [MatDividerModule, MatIconModule, MatMenuModule, TranslateModule],
})
export class AddPlaylistMenuComponent {
readonly menu = viewChild.required<MatMenu>('addPlaylistMenu');
readonly playlistTypeSelected = output<PlaylistType>();
onPlaylistTypeClick(type: PlaylistType): void {
this.playlistTypeSelected.emit(type);
}
}
@@ -1,224 +0,0 @@
import {
Component,
ElementRef,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
} from '@angular/core';
import Artplayer from 'artplayer';
import Hls from 'hls.js';
import { getExtensionFromUrl } from 'm3u-utils';
import mpegts from 'mpegts.js';
import { Channel } from 'shared-interfaces';
Artplayer.AUTO_PLAYBACK_TIMEOUT = 10000;
@Component({
selector: 'app-art-player',
imports: [],
template: `<div #artplayer class="artplayer-container"></div>`,
styles: [
`
:host {
display: block;
width: 100%;
height: 100%;
}
.artplayer-container {
width: 100%;
height: 100%;
}
`,
],
})
export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
@Input() channel!: Channel;
@Input() volume = 1;
@Input() showCaptions = false;
@Input() startTime = 0;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
private player!: Artplayer;
private hls: Hls | null = null;
private mpegtsPlayer: mpegts.Player | null = null;
private readonly elementRef = inject(ElementRef);
ngOnInit(): void {
this.initPlayer();
}
ngOnDestroy(): void {
this.destroyPlayer();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['channel'] && !changes['channel'].firstChange) {
this.destroyPlayer();
this.initPlayer();
}
}
private destroyPlayer(): void {
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
this.mpegtsPlayer.detachMediaElement();
this.mpegtsPlayer.destroy();
this.mpegtsPlayer = null;
}
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.player) {
this.player.destroy();
}
}
private initPlayer(): void {
const el = this.elementRef.nativeElement.querySelector(
'.artplayer-container'
);
const extension = getExtensionFromUrl(this.channel?.url ?? '');
const isLive = extension === 'm3u8' || extension === 'ts';
this.player = new Artplayer({
container: el,
url: this.channel.url + (this.channel.epgParams || ''),
volume: this.volume,
isLive: isLive,
autoplay: true,
type: this.getVideoType(this.channel.url),
pip: true,
autoPlayback: true,
autoSize: true,
autoMini: true,
screenshot: true,
setting: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
playsInline: true,
airplay: true,
backdrop: true,
mutex: true,
theme: '#ff0000',
customType: {
m3u8: (video: HTMLVideoElement, url: string) => {
if (Hls.isSupported()) {
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls();
this.hls.loadSource(url);
this.hls.attachMedia(video);
this.setupHlsAudioTracks();
} else if (
video.canPlayType('application/vnd.apple.mpegurl')
) {
video.src = url;
}
},
ts: (video: HTMLVideoElement, url: string) => {
if (mpegts.isSupported()) {
if (this.mpegtsPlayer) {
this.mpegtsPlayer.destroy();
}
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
url: url,
});
this.mpegtsPlayer.attachMediaElement(video);
this.mpegtsPlayer.load();
this.mpegtsPlayer.play();
}
},
mkv: function (video: HTMLVideoElement, url: string) {
video.src = url;
video.onerror = () => {
console.error('Error loading MKV file:', video.error);
video.src = url;
};
},
},
});
if (this.startTime > 0) {
this.player.on('ready', () => {
this.player.seek = this.startTime;
});
}
this.player.on('video:timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime,
duration: this.player.duration,
});
});
}
/**
* Listens for HLS.js audio tracks and adds a settings menu entry
* to ArtPlayer for switching between available audio tracks.
*/
private setupHlsAudioTracks(): void {
if (!this.hls) return;
const hls = this.hls;
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => {
const tracks = hls.audioTracks;
if (!tracks || tracks.length <= 1) return;
this.player.setting.add({
html: 'Audio',
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="white">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>`,
width: 220,
tooltip: tracks[hls.audioTrack]?.name || '',
selector: tracks.map((track, index) => ({
html: track.name || track.lang || `Track ${index + 1}`,
default: index === hls.audioTrack,
})),
onSelect: function (this: Artplayer, item: any) {
const selectedIndex = tracks.findIndex(
(t, i) =>
(t.name || t.lang || `Track ${i + 1}`) === item.html
);
if (selectedIndex >= 0) {
hls.audioTrack = selectedIndex;
}
return item.html;
},
} as any);
});
}
private getVideoType(url: string): string {
const extension = getExtensionFromUrl(url);
switch (extension) {
case 'mkv':
return 'video/matroska';
case 'm3u8':
return 'm3u8';
case 'mp4':
return 'mp4';
case 'ts':
return 'ts';
default:
return 'auto';
}
}
}
@@ -1,123 +0,0 @@
:host {
display: flex;
justify-content: center;
margin: 20px 0;
}
#audio-player {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.radio-logo {
border-radius: 14px;
height: 300px;
width: 300px;
display: flex;
align-items: center;
justify-content: center;
img {
max-width: 300px;
max-height: 300px;
}
}
.volume-panel {
display: flex;
align-self: center;
align-items: center;
input {
flex: 1;
}
}
.controls {
height: 100px;
align-items: center;
display: flex;
gap: 8px;
justify-content: center;
}
.playing {
width: 36px;
height: 36px;
border-radius: 0.3rem;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0.5rem;
box-sizing: border-box;
}
.playing-bar {
display: inline-block;
background: white;
width: 30%;
height: 100%;
animation: up-and-down 1.3s ease infinite alternate;
}
.playing-bar-stopped {
display: inline-block;
background: white;
width: 30%;
height: 100%;
}
.playing-bar1 {
height: 60%;
}
.playing-bar2 {
height: 30%;
animation-delay: -2.2s;
}
.playing-bar3 {
height: 75%;
animation-delay: -3.7s;
}
@keyframes up-and-down {
10% {
height: 30%;
}
30% {
height: 100%;
}
60% {
height: 50%;
}
80% {
height: 75%;
}
100% {
height: 60%;
}
}
.icon-button-large {
transform: scale(2);
margin: 0 20px;
}
@media only screen and (max-width: 599px) {
.radio-logo {
height: 200px;
width: 200px;
img {
max-width: 200px;
max-height: 200px;
}
}
}
@@ -1,130 +0,0 @@
import {
Component,
ElementRef,
inject,
Input,
OnChanges,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSliderModule } from '@angular/material/slider';
import { Store } from '@ngrx/store';
import { ChannelActions } from 'm3u-state';
@Component({
selector: 'app-audio-player',
template: `
<div id="audio-player">
<div class="radio-logo">
<img [src]="icon" alt="radio icon" />
</div>
<audio preload="metadata" autoplay #audio>
<source [src]="url" />
</audio>
<div class="controls">
<button mat-icon-button (click)="switchChannel('previous')">
<mat-icon>skip_previous</mat-icon>
</button>
@if (playState === 'paused') {
<button
class="icon-button-large"
mat-icon-button
(click)="play()"
>
<mat-icon>play_arrow</mat-icon>
</button>
}
@if (playState === 'play') {
<button
class="icon-button-large"
mat-icon-button
(click)="stop()"
>
<mat-icon>pause</mat-icon>
</button>
}
<button mat-icon-button (click)="switchChannel('next')">
<mat-icon>skip_next</mat-icon>
</button>
</div>
<div class="volume-panel">
@if (playState === 'play') {
<div class="playing">
<span class="playing-bar playing-bar1"></span>
<span class="playing-bar playing-bar2"></span>
<span class="playing-bar playing-bar3"></span>
</div>
}
@if (playState === 'paused') {
<div class="playing">
<span class="playing-bar-stopped playing-bar1"></span>
<span class="playing-bar-stopped playing-bar2"></span>
<span class="playing-bar-stopped playing-bar3"></span>
</div>
}
<mat-slider min="0" max="1" step="0.1" color="accent">
<input matSliderThumb [(ngModel)]="audio.volume" />
</mat-slider>
<button mat-icon-button (click)="mute()">
@if (audio.volume > 0 && !audio.muted) {
<mat-icon>volume_up</mat-icon>
}
@if (audio.volume === 0 || audio.muted) {
<mat-icon>volume_off</mat-icon>
}
</button>
</div>
</div>
`,
styleUrls: ['./audio-player.component.scss'],
imports: [MatSliderModule, MatIconModule, MatButtonModule, FormsModule],
})
export class AudioPlayerComponent implements OnChanges {
@Input() icon!: string;
@Input({ required: true }) url!: string;
playState: 'play' | 'paused' = 'paused';
fallbackVolume!: number;
@ViewChild('audio', { static: true }) audio!: ElementRef<HTMLAudioElement>;
private store = inject(Store);
ngOnChanges(changes: SimpleChanges): void {
this.audio.nativeElement.src = changes['url'].currentValue;
this.audio.nativeElement.load();
this.play();
}
play() {
const playPromise = this.audio.nativeElement.play();
if (playPromise !== undefined) {
playPromise.catch((error) => {
console.log(error);
});
}
this.playState = 'play';
}
stop() {
this.audio.nativeElement.pause();
this.playState = 'paused';
}
mute() {
this.audio.nativeElement.muted = !this.audio.nativeElement.muted;
if (this.audio.nativeElement.muted) {
this.fallbackVolume = this.audio.nativeElement.volume;
this.audio.nativeElement.volume = 0;
} else this.audio.nativeElement.volume = this.fallbackVolume;
}
switchChannel(direction: 'next' | 'previous') {
this.store.dispatch(ChannelActions.setAdjacentChannelAsActive({ direction }));
}
}
@@ -16,7 +16,7 @@ import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import groupBy from 'lodash/groupBy';
import { EpgService } from '@iptvnator/epg/data-access';
import {
ChannelActions,
FavoritesActions,
@@ -25,12 +25,22 @@ import {
selectFavorites,
} from 'm3u-state';
import { BehaviorSubject, combineLatest, filter, map, skipWhile } from 'rxjs';
import { EpgService } from 'services';
import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces';
import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component';
import { FavoritesViewComponent } from './favorites-view/favorites-view.component';
import { GroupsViewComponent } from './groups-view/groups-view.component';
function groupChannelsByTitle(channels: Channel[]): Record<string, Channel[]> {
return channels.reduce<Record<string, Channel[]>>((groups, channel) => {
const key = channel.group?.title ?? '';
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(channel);
return groups;
}, {});
}
@Component({
selector: 'app-channel-list-container',
templateUrl: './channel-list-container.component.html',
@@ -123,7 +133,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
/** Object with channels sorted by groups */
readonly groupedChannels = computed(() =>
groupBy(this.displayedChannels(), 'group.title')
groupChannelsByTitle(this.displayedChannels())
);
/** Selected channel */
@@ -14,8 +14,8 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgItemDescriptionComponent } from '@iptvnator/ui/epg';
import { EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../../epg-list/epg-item-description/epg-item-description.component';
@Component({
selector: 'app-channel-list-item',
@@ -1,63 +0,0 @@
<div class="program-dialog">
<!-- Header with channel and time -->
<header class="program-header">
<div class="time-block">
<div class="time-display">
<span class="time-start">{{ formatTime(epgProgram.start) }}</span>
<span class="time-separator">—</span>
<span class="time-end">{{ formatTime(epgProgram.stop) }}</span>
</div>
<div class="date-display">{{ formatDate(epgProgram.start) }}</div>
</div>
@if (channelName) {
<div class="channel-badge">
<span class="channel-icon">📺</span>
<span class="channel-name">{{ channelName }}</span>
</div>
}
</header>
<!-- Title section -->
<div class="program-title-section">
<h1 class="program-title">{{ epgProgram.title }}</h1>
@if (epgProgram.category) {
<span class="category-tag">{{ epgProgram.category }}</span>
}
</div>
<!-- Content -->
<div class="program-content">
@if (epgProgram.desc) {
<p class="program-description">{{ epgProgram.desc }}</p>
}
<!-- Meta info grid -->
<div class="meta-grid">
@if (epgProgram.episodeNum) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }}</span>
<span class="meta-value">{{ epgProgram.episodeNum }}</span>
</div>
}
@if (epgProgram.rating) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}</span>
<span class="meta-value rating">{{ epgProgram.rating }}</span>
</div>
}
@if (duration) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }}</span>
<span class="meta-value">{{ duration }}</span>
</div>
}
</div>
</div>
<!-- Footer -->
<footer class="program-footer">
<button class="close-button" mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</footer>
</div>
@@ -1,300 +0,0 @@
// Broadcast control room palette
$bg-deep: #0a0a0f;
$bg-surface: #12121a;
$bg-elevated: #1a1a24;
$accent-cyan: #00d4aa;
$accent-purple: #8b5cf6;
$text-primary: #f8fafc;
$text-secondary: rgba(248, 250, 252, 0.7);
$text-muted: rgba(248, 250, 252, 0.4);
$border-subtle: rgba(255, 255, 255, 0.08);
.program-dialog {
background: linear-gradient(180deg, $bg-surface 0%, $bg-deep 100%);
color: $text-primary;
min-width: 380px;
max-width: 480px;
border-radius: 16px;
overflow: hidden;
position: relative;
// Ambient glow effect
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px;
background: radial-gradient(
ellipse at top center,
rgba($accent-cyan, 0.12) 0%,
transparent 70%
);
pointer-events: none;
}
}
// Header with time and channel
.program-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px 24px 16px;
position: relative;
z-index: 1;
}
.time-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.time-display {
display: flex;
align-items: center;
gap: 8px;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
.time-start {
color: $accent-cyan;
text-shadow: 0 0 20px rgba($accent-cyan, 0.4);
}
.time-separator {
color: $text-muted;
font-weight: 300;
}
.time-end {
color: $text-secondary;
}
}
.date-display {
font-size: 13px;
color: $text-muted;
letter-spacing: 0.02em;
}
.channel-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba($accent-purple, 0.15);
border: 1px solid rgba($accent-purple, 0.3);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: $text-primary;
.channel-icon {
font-size: 14px;
}
.channel-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
// Title section
.program-title-section {
padding: 0 24px 20px;
border-bottom: 1px solid $border-subtle;
}
.program-title {
margin: 0 0 12px;
font-size: 22px;
font-weight: 600;
line-height: 1.3;
color: $text-primary;
letter-spacing: -0.01em;
}
.category-tag {
display: inline-block;
padding: 6px 12px;
background: rgba($accent-cyan, 0.1);
border: 1px solid rgba($accent-cyan, 0.25);
border-radius: 6px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $accent-cyan;
}
// Content section
.program-content {
padding: 20px 24px;
}
.program-description {
margin: 0 0 24px;
font-size: 14px;
line-height: 1.7;
color: $text-secondary;
}
// Meta grid
.meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid $border-subtle;
}
.meta-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $text-muted;
}
.meta-value {
font-size: 14px;
font-weight: 500;
color: $text-primary;
&.rating {
color: $accent-purple;
}
}
// Footer
.program-footer {
padding: 16px 24px 24px;
display: flex;
justify-content: flex-end;
}
.close-button {
padding: 10px 24px;
background: transparent;
border: 1px solid rgba($accent-cyan, 0.4);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: $accent-cyan;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba($accent-cyan, 0.1);
border-color: $accent-cyan;
box-shadow: 0 0 20px rgba($accent-cyan, 0.2);
}
}
// Override Material dialog styling
::ng-deep .mat-mdc-dialog-container {
--mdc-dialog-container-color: transparent;
--mdc-dialog-container-shape: 16px;
.mdc-dialog__surface {
background: transparent !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.05) !important;
}
}
// Light theme adjustments
::ng-deep .light-theme {
.program-dialog {
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
color: #1e293b;
&::before {
background: radial-gradient(
ellipse at top center,
rgba(#059669, 0.08) 0%,
transparent 70%
);
}
}
.time-display {
.time-start {
color: #059669;
text-shadow: none;
}
.time-end {
color: #64748b;
}
}
.date-display {
color: #94a3b8;
}
.channel-badge {
background: rgba(#7c3aed, 0.08);
border-color: rgba(#7c3aed, 0.2);
color: #1e293b;
}
.program-title {
color: #0f172a;
}
.program-title-section {
border-bottom-color: rgba(0, 0, 0, 0.06);
}
.category-tag {
background: rgba(#059669, 0.08);
border-color: rgba(#059669, 0.2);
color: #059669;
}
.program-description {
color: #475569;
}
.meta-item {
background: rgba(0, 0, 0, 0.02);
border-color: rgba(0, 0, 0, 0.06);
}
.meta-label {
color: #94a3b8;
}
.meta-value {
color: #1e293b;
&.rating {
color: #7c3aed;
}
}
.close-button {
border-color: rgba(#059669, 0.4);
color: #059669;
&:hover {
background: rgba(#059669, 0.08);
border-color: #059669;
box-shadow: none;
}
}
}
@@ -1,67 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from './epg-item-description.component';
describe('EpgItemDescriptionComponent', () => {
let component: EpgItemDescriptionComponent;
let fixture: ComponentFixture<EpgItemDescriptionComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
EpgItemDescriptionComponent,
MockModule(MatDialogModule),
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'TV Show 1',
desc: 'Highly interesting show about pets',
category: 'Fun',
} as unknown as EpgProgram,
},
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EpgItemDescriptionComponent);
component = fixture.componentInstance;
component.epgProgram = TestBed.inject(MAT_DIALOG_DATA);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render epg details in the dialog', () => {
fixture.detectChanges();
const titleElement = fixture.debugElement.query(
By.css('[data-test="title"]')
);
expect(titleElement.nativeElement.textContent.trim()).toContain(
'TV Show 1'
);
const categoryElement = fixture.debugElement.query(
By.css('[data-test="category"]')
);
expect(categoryElement.nativeElement.textContent.trim()).toContain(
'Fun'
);
const descElement = fixture.debugElement.query(
By.css('[data-test="desc"]')
);
expect(descElement.nativeElement.textContent.trim()).toContain(
'Highly interesting show about pets'
);
});
});
@@ -1,65 +0,0 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgProgram } from 'shared-interfaces';
import { format, differenceInMinutes } from 'date-fns';
@Component({
selector: 'app-epg-item-description',
templateUrl: './epg-item-description.component.html',
styleUrls: ['./epg-item-description.component.scss'],
imports: [MatButtonModule, MatDialogModule, TranslatePipe],
})
export class EpgItemDescriptionComponent {
dialogData = inject<any>(MAT_DIALOG_DATA);
epgProgram: EpgProgram;
channelName: string | null = null;
duration: string | null = null;
constructor() {
this.epgProgram = this.dialogData;
// Check multiple possible field names for channel name
this.channelName = this.dialogData.channelName
|| this.dialogData.channel_name
|| this.dialogData.display_name
|| null;
this.duration = this.calculateDuration();
}
formatTime(dateStr: string): string {
if (!dateStr) return '';
try {
return format(new Date(dateStr), 'HH:mm');
} catch {
return '';
}
}
formatDate(dateStr: string): string {
if (!dateStr) return '';
try {
return format(new Date(dateStr), 'EEEE, MMMM d');
} catch {
return '';
}
}
private calculateDuration(): string | null {
if (!this.epgProgram.start || !this.epgProgram.stop) return null;
try {
const start = new Date(this.epgProgram.start);
const stop = new Date(this.epgProgram.stop);
const mins = differenceInMinutes(stop, start);
if (mins < 60) {
return `${mins} min`;
}
const hours = Math.floor(mins / 60);
const remainingMins = mins % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
} catch {
return null;
}
}
}
@@ -1,28 +0,0 @@
<span class="time"
>{{ item.start | momentDate: 'HH:mm' }}
-
{{ item.stop | momentDate: 'HH:mm' }}</span
>
@if (timeNow >= item.start && timeNow <= item.stop) {
<span class="live badge" [matTooltip]="'EPG.LIVE_NOW' | translate"
>live</span
>
}
@if (
item.start < timeNow && item.stop < timeNow && item.start > timeshiftUntil
) {
<span class="rec badge" [matTooltip]="'EPG.TIMESHIFT_AVAILABLE' | translate"
>r</span
>
}
@if (item?.desc) {
<span
(click)="$event.stopPropagation(); showDescription(item)"
matListItemMeta
class="description-icon"
[matTooltip]="'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate"
><mat-icon>info</mat-icon></span
>
}
@@ -1,72 +0,0 @@
:host {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
}
.time {
font-size: 0.76rem;
line-height: 1;
font-weight: 600;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
opacity: 0.76;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 42px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
line-height: 1.35;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
border: 1px solid transparent;
}
.live {
color: #ff8f8f;
background: rgba(255, 95, 95, 0.12);
border-color: rgba(255, 95, 95, 0.28);
}
.rec {
color: #9abfff;
background: color-mix(in srgb, var(--app-selection-color) 14%, transparent);
border-color: color-mix(in srgb, var(--app-selection-border) 72%, transparent);
}
.description-icon {
margin-inline-start: auto;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
cursor: pointer;
opacity: 0.7;
transition:
opacity 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
mat-icon {
width: 18px;
height: 18px;
font-size: 18px;
}
&:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
transform: translateY(-1px);
}
}
@@ -1,97 +0,0 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslateModule } from '@ngx-translate/core';
import moment from 'moment';
import { MockModule, MockPipe } from 'ng-mocks';
import { EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
import { EpgListItemComponent } from './epg-list-item.component';
const EPG_PROGRAM_ITEM = {
start: moment(Date.now()).format('YYYYMMDD'),
stop: moment(Date.now()).format('YYYYMMDD'),
channel: '12345',
title: [{ lang: 'en', value: 'NOW on PBS' }],
desc: [
{
lang: 'en',
value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
},
],
date: ['20080711'],
category: [
{ lang: 'en', value: 'Newsmagazine' },
{ lang: 'en', value: 'Interview' },
],
episodeNum: [
{ system: 'dd_progid', value: 'EP01006886.0028' },
{ system: 'onscreen', value: '427' },
],
previouslyShown: [{ start: '20080711000000' }],
subtitles: [{ type: 'teletext' }],
rating: [
{
system: 'VCHIP',
value: 'TV-G',
},
],
credits: [
{
role: 'actor',
name: 'Peter Bergman',
},
],
icon: [
'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg',
],
audio: [],
_attributes: {
start: moment(Date.now()).format('YYYYMMDD'),
stop: moment(Date.now()).format('YYYYMMDD'),
},
};
describe('EpgListItemComponent', () => {
let component: EpgListItemComponent;
let fixture: ComponentFixture<EpgListItemComponent>;
let dialog: MatDialog;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [EpgListItemComponent, MockPipe(MomentDatePipe)],
imports: [
MockModule(MatDialogModule),
MockModule(MatListModule),
MockModule(MatIconModule),
MockModule(MatTooltipModule),
TranslateModule.forRoot(),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EpgListItemComponent);
dialog = TestBed.inject(MatDialog);
component = fixture.componentInstance;
component.item = EPG_PROGRAM_ITEM;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should trigger the function to open the details dialog', () => {
jest.spyOn(dialog, 'open');
component.showDescription({} as EpgProgram);
expect(dialog.open).toHaveBeenCalledTimes(1);
expect(dialog.open).toHaveBeenCalledWith(EpgItemDescriptionComponent, {
data: {},
});
});
});
@@ -1,37 +0,0 @@
import { Component, inject, Input } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component';
@Component({
imports: [MomentDatePipe, MatIcon, MatTooltip, TranslatePipe],
selector: 'app-epg-list-item',
templateUrl: './epg-list-item.component.html',
styleUrls: ['./epg-list-item.component.scss'],
})
export class EpgListItemComponent {
private dialog = inject(MatDialog);
/** EPG Program to render */
@Input() item!: EpgProgram;
/** Actual time */
@Input() timeNow!: string;
/** Aviability of the timeshift function until date */
@Input() timeshiftUntil!: string;
/**
* Opens the dialog with details about the selected program
* @param program selected epg program
*/
showDescription(program: EpgProgram): void {
this.dialog.open(EpgItemDescriptionComponent, {
data: program,
});
}
}
@@ -1,136 +0,0 @@
@let items = filteredItems$ | async;
@let timeshiftUntil = timeshiftUntil$ | async;
<!-- channel info panel -->
<div id="channel-header" color="primary">
<div class="channel-info">
<div class="channel-icon-shell" aria-hidden="true">
@if (showChannelIconFallback(channel)) {
<mat-icon class="channel-icon-fallback">live_tv</mat-icon>
}
@if (getChannelIcon(channel)) {
<img
[src]="getChannelIcon(channel)"
width="48"
alt="icon"
(error)="onChannelIconError($event)"
/>
}
</div>
<div class="channel-details">
<div class="channel-name">
{{ getChannelDisplayName(channel) }}
</div>
<div class="program-name">
{{
playingNow
? (playingNow.title || 'EPG.LIVE_STREAM' | translate)
: ('EPG.LIVE_STREAM' | translate)
}}
</div>
</div>
</div>
<div class="header-divider"></div>
<div id="date-navigator">
<button
class="previous-day"
mat-icon-button
(click)="changeDate('prev')"
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
>
<mat-icon>chevron_left</mat-icon>
</button>
<span class="selected-date">
{{ dateToday | momentDate: 'MMMM Do, dddd' : 'YYYY-MM-DD' }}</span
>
<button
class="next-day"
mat-icon-button
(click)="changeDate('next')"
[matTooltip]="'EPG.NEXT_DAY' | translate"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
</div>
@if (timeshiftUntil) {
<!-- program list -->
<div id="program-list" #programList>
@if (items?.length > 0) {
@for (program of items; track $index) {
@if (
program.start < timeshiftUntil || program.start >= timeNow
) {
<div
class="program-item"
[class.current-program]="isProgramPlaying(program)"
>
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[item]="program"
/>
</div>
<div class="program-title">
{{ program?.title }}
</div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="calculateProgress(program)"
></div>
</div>
</div>
}
</div>
} @else {
<div
class="program-item clickable"
[class.current-program]="isProgramPlaying(program)"
[class.active]="
timeNow >= program.start && timeNow <= program.stop
"
(click)="
setEpgProgram(
program,
timeNow >= program.start &&
timeNow <= program.stop,
program.start > timeshiftUntil
)
"
>
<div class="program-meta">
<app-epg-list-item
[timeshiftUntil]="timeshiftUntil"
[timeNow]="timeNow"
[item]="program"
/>
</div>
<div
class="program-title"
[innerHTML]="program.title"
></div>
@if (isProgramPlaying(program)) {
<div class="program-progress">
<div class="epg-progress-track">
<div
class="epg-progress-fill"
[style.width.%]="calculateProgress(program)"
></div>
</div>
</div>
}
</div>
}
}
} @else {
<div class="epg-not-available">
{{ 'EPG.EPG_NOT_AVAILABLE_DATE' | translate }}
</div>
}
</div>
}
@@ -1,255 +0,0 @@
:host {
display: flex;
flex-direction: column;
position: absolute;
inset: 0;
height: 100%;
overflow-x: hidden;
background: transparent;
}
.header-divider {
height: 1px;
background: rgba(255, 255, 255, 0.08);
}
#channel-header {
display: flex;
flex-direction: column;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
.channel-info {
display: flex;
align-items: center;
padding: 14px 16px 12px;
gap: 14px;
}
}
.channel-icon-shell {
position: relative;
width: 52px;
height: 52px;
flex-shrink: 0;
border-radius: 12px;
overflow: hidden;
display: grid;
place-items: center;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.12);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 1px 1px rgba(0, 0, 0, 0.15);
img {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
box-sizing: border-box;
}
}
.channel-icon-fallback {
position: absolute;
inset: 0;
margin: auto;
width: 20px;
height: 20px;
font-size: 20px;
opacity: 0.46;
}
.channel-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.channel-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1rem;
line-height: 1.2;
font-weight: 600;
letter-spacing: 0.01em;
}
.program-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.84rem;
line-height: 1.2;
opacity: 0.82;
}
#date-navigator {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px 10px;
gap: 8px;
button {
border-radius: 10px;
opacity: 0.84;
transition:
opacity 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease;
&:hover:not([disabled]) {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
transform: translateY(-1px);
}
&[disabled] {
opacity: 0.3;
}
}
}
.selected-date {
flex: 1;
text-align: center;
font-size: 0.83rem;
line-height: 1.2;
font-weight: 600;
letter-spacing: 0.01em;
opacity: 0.8;
}
#program-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 10px 10px 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.program-item {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.045);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 10px 20px -20px rgba(0, 0, 0, 0.35);
transition:
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
cursor: default;
&.clickable {
cursor: pointer;
&:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.065);
transform: translateY(-1px);
}
}
&.active {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
border-color: var(--app-selection-border);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
.program-title {
color: var(--app-selection-color);
}
}
}
.program-meta {
display: flex;
align-items: center;
gap: 8px;
}
.program-title {
font-size: 0.94rem;
line-height: 1.4;
font-weight: 560;
letter-spacing: 0.005em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.program-progress {
padding-top: 2px;
}
.epg-progress-track {
position: relative;
width: 100%;
height: 6px;
overflow: hidden;
border-radius: 999px;
background-color: rgba(127, 127, 127, 0.28);
background-image: linear-gradient(
180deg,
rgba(255, 255, 255, 0.12),
rgba(255, 255, 255, 0)
);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
inset 0 1px 1px rgba(0, 0, 0, 0.18);
}
.epg-progress-fill {
position: absolute;
inset-block: 0;
inset-inline-start: 0;
border-radius: inherit;
background-color: var(--app-selection-color);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0.18),
rgba(255, 255, 255, 0)
);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
0 0 10px -2px var(--app-selection-glow);
transition: width 0.35s ease;
}
.epg-not-available {
margin: auto 10px;
padding: 22px;
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 14px;
text-align: center;
font-size: 0.88rem;
opacity: 0.66;
}
@@ -1,193 +0,0 @@
/* import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent, MockModule, MockPipe, MockProvider } from 'ng-mocks';
import { BehaviorSubject, Observable } from 'rxjs';
import { DataService, ElectronServiceStub, EpgService } from 'services';
import { Channel, EpgProgram } from 'shared-interfaces';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
import { EpgListComponent } from './epg-list.component';
// Update moment mock to handle namespace import
jest.mock('moment', () => {
const momentFunc = () => ({
format: () => '2023-01-01',
subtract: () => ({
toISOString: () => '2023-01-01T00:00:00Z',
format: () => '2023-01-01',
}),
diff: () => 0,
add: () => ({
format: () => '2023-01-01',
}),
});
momentFunc.default = momentFunc;
return momentFunc;
});
describe('EpgListComponent', () => {
let component: EpgListComponent;
let fixture: ComponentFixture<EpgListComponent>;
let electronService: DataService;
let mockStore: MockStore;
let epgService: EpgService;
const actions$ = new Observable<Actions>();
const MOCKED_PROGRAMS = {
channel: {
id: '12345',
name: [
{
lang: 'ab',
value: 'test me',
},
{
lang: 'ar',
value: 'ar test',
},
],
icon: [],
url: [],
},
items: [
{
start: '2023-01-01',
stop: '2023-01-01',
channel: '12345',
title: [{ lang: 'en', value: 'NOW on PBS' }],
desc: [
{
lang: 'en',
value: "Jordan's Queen Rania has made job creation a priority to help curb the staggering unemployment rates among youths in the Middle East.",
},
],
date: ['20080711'],
category: [
{ lang: 'en', value: 'Newsmagazine' },
{ lang: 'en', value: 'Interview' },
],
episodeNum: [
{ system: 'dd_progid', value: 'EP01006886.0028' },
{ system: 'onscreen', value: '427' },
],
previouslyShown: [{ start: '20080711000000' }],
subtitles: [{ type: 'teletext' }],
rating: [
{
system: 'VCHIP',
value: 'TV-G',
},
],
credits: [
{
role: 'actor',
name: 'Peter Bergman',
},
],
icon: [
'http://imageswoapi.whatsonindia.com/WhatsOnTV/images/ProgramImages/xlarge/38B4DE4E9A7132257749051B6C8B4F699DB264F4V.jpg',
],
audio: [],
_attributes: {
start: '2023-01-01',
stop: '2023-01-01',
},
},
],
};
beforeEach(waitForAsync(() => {
const mockEpgService = {
currentEpgPrograms$: new BehaviorSubject<EpgProgram[]>([]),
};
TestBed.configureTestingModule({
imports: [
EpgListComponent,
MockPipe(MomentDatePipe),
TranslateModule.forRoot(),
MockComponent(EpgListItemComponent),
MockModule(MatIconModule),
MockModule(MatTooltipModule),
MockModule(MatListModule),
MockModule(MatDialogModule),
],
providers: [
{ provide: DataService, useClass: ElectronServiceStub },
{ provide: EpgService, useValue: mockEpgService },
MockProvider(MatDialog),
provideMockStore(),
provideMockActions(actions$),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EpgListComponent);
component = fixture.componentInstance;
electronService = TestBed.inject(DataService);
epgService = TestBed.inject(EpgService);
mockStore = TestBed.inject(MockStore);
mockStore.setState({
playlistState: {
active: {
id: '',
url: '',
name: '',
group: { title: '' },
tvg: {
rec: '3',
},
} as unknown as Channel,
},
});
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should handle an empty epg programs object', () => {
const emptyPrograms: EpgProgram[] = [];
(epgService.currentEpgPrograms$ as BehaviorSubject<EpgProgram[]>).next(
emptyPrograms
);
component.handleEpgData(emptyPrograms);
fixture.detectChanges();
expect(component.timeNow).toBeTruthy();
expect(component.dateToday).toBeTruthy();
expect(component.channel).toBeNull();
// Use async pipe or subscribe to test the Observable
component.items$.subscribe((items) => {
expect(items).toHaveLength(0);
});
});
it('should set epg program as active', () => {
jest.spyOn(mockStore, 'dispatch');
component.setEpgProgram(MOCKED_PROGRAMS.items[0], false, true);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
expect(mockStore.dispatch).toHaveBeenCalledWith({
program: MOCKED_PROGRAMS.items[0],
type: expect.stringContaining('epg program'),
});
});
it('should reset active epg program', () => {
jest.spyOn(mockStore, 'dispatch');
component.setEpgProgram(MOCKED_PROGRAMS.items[0], true);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
component.setEpgProgram(MOCKED_PROGRAMS.items[0], true, true);
expect(mockStore.dispatch).toHaveBeenCalledTimes(2);
});
});
*/
@@ -1,375 +0,0 @@
import { AsyncPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
effect,
inject,
Injector,
OnInit,
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
selectActive,
EpgActions,
} from 'm3u-state';
import moment from 'moment';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { EpgService } from 'services';
import { EpgChannel, EpgProgram } from 'shared-interfaces';
import { EpgListItemComponent } from './epg-list-item/epg-list-item.component';
export interface EpgData {
channel: EpgChannel;
items: EpgProgram[];
}
const DATE_FORMAT = 'YYYY-MM-DD';
@Component({
imports: [
AsyncPipe,
EpgListItemComponent,
FormsModule,
MatIcon,
MatIconButton,
MatTooltip,
MomentDatePipe,
TranslatePipe,
],
selector: 'app-epg-list',
templateUrl: './epg-list.component.html',
styleUrls: ['./epg-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EpgListComponent implements OnInit {
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly injector = inject(Injector);
/** Channel info in EPG format */
channel!: EpgChannel;
/** Today as formatted date string */
dateToday!: string;
/** Array with EPG programs */
items$ = this.epgService.currentEpgPrograms$;
/** Object with epg programs for the active channel */
programs!: {
payload: EpgData;
};
/** EPG selected program */
playingNow!: EpgProgram;
channelIconFailed = false;
/** Selected date */
selectedDate!: string;
/** Current time as formatted string */
timeNow!: string;
/** Timeshift availability date, based on tvg-rec value from the channel */
readonly timeshiftUntil$ = this.store.select(selectActive).pipe(
map((active) => {
// Create EpgChannel with proper structure
const displayNames = active?.name
? [{ lang: '', value: active.name }]
: [];
const icons = active?.tvg?.logo ? [{ src: active.tvg.logo }] : [];
this.channel = {
id: active?.tvg?.id || '',
displayName: displayNames,
url: active?.url ? [active.url] : [],
icon: icons,
};
this.channelIconFailed = false;
return (
active?.tvg?.rec || active?.timeshift || active?.catchup?.days
);
}),
map((value) => {
const days = Number(value ?? 0) || 0;
return moment().subtract(days, 'days').toISOString();
})
);
private readonly selectedDate$ = new BehaviorSubject<string>(
moment().format(DATE_FORMAT)
);
readonly programList = viewChild<ElementRef<HTMLElement>>('programList');
private scrollScheduled = false;
/** Filtered EPG programs based on selected date */
filteredItems$ = combineLatest([this.items$, this.selectedDate$]).pipe(
map(([items, selectedDate]) =>
items
.filter(
(item) =>
moment(item.start).format('YYYY-MM-DD') === selectedDate
)
.sort((a, b) => moment(a.start).diff(moment(b.start)))
)
);
/**
* Helper function to get channel display name
*/
getChannelDisplayName(channel: EpgChannel): string {
if (
!channel ||
!channel.displayName ||
channel.displayName.length === 0
) {
return '';
}
// Return first available display name
return channel.displayName[0]?.value || '';
}
/**
* Helper function to get channel icon
*/
getChannelIcon(channel: EpgChannel): string {
if (!channel || !channel.icon || channel.icon.length === 0) {
return '';
}
// Return first available icon src
return channel.icon[0]?.src || '';
}
showChannelIconFallback(channel: EpgChannel): boolean {
return !this.getChannelIcon(channel) || this.channelIconFailed;
}
onChannelIconError(event: Event): void {
this.channelIconFailed = true;
(event.target as HTMLImageElement | null)?.style.setProperty(
'display',
'none'
);
this.cdr.markForCheck();
}
/**
* Subscribe for values from the store on component init
*/
ngOnInit(): void {
effect(
(onCleanup) => {
const programList = this.programList()?.nativeElement;
if (!programList || typeof ResizeObserver === 'undefined') {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
const [entry] = entries;
if (
entry &&
entry.contentRect.height > 0 &&
entry.contentRect.width > 0
) {
this.scheduleScrollToCurrentProgram();
}
});
resizeObserver.observe(programList);
onCleanup(() => resizeObserver.disconnect());
},
{ injector: this.injector }
);
this.items$.subscribe((programs) => this.handleEpgData(programs));
this.dateToday = moment().format(DATE_FORMAT);
this.selectedDate$.next(this.dateToday);
}
/**
* Handles incoming epg programs for the active channel from the main process
* @param programs
*/
handleEpgData(programs: EpgProgram[]): void {
this.timeNow = new Date().toISOString();
this.dateToday = moment().format(DATE_FORMAT);
// Dispatch EPG availability flag
this.store.dispatch(
EpgActions.setEpgAvailableFlag({ value: programs.length > 0 })
);
if (programs.length > 0) {
this.setPlayingNow();
} else {
this.channel = {} as EpgChannel;
// Clear the current EPG program when no programs available
this.store.dispatch(EpgActions.resetActiveEpgProgram());
}
// Trigger change detection for OnPush strategy
this.cdr.markForCheck();
this.scheduleScrollToCurrentProgram();
}
/**
* Selects the program based on the active date
*/
selectPrograms(programs: { payload: EpgData }): EpgProgram[] {
const selectedDate = moment(this.dateToday).format('YYYY-MM-DD');
return programs.payload?.items
.filter(
(item) =>
moment(item.start).format('YYYY-MM-DD') === selectedDate
)
.map((program) => ({
...program,
start: program.start, // Keep ISO format
stop: program.stop, // Keep ISO format
}))
.sort((a, b) => moment(a.start).diff(moment(b.start)));
}
/**
* Changes the date to update the epg list with programs
* @param direction direction to switch
*/
changeDate(direction: 'next' | 'prev'): void {
const newDate = moment(this.selectedDate$.value)
[direction === 'next' ? 'add' : 'subtract'](1, 'days')
.format(DATE_FORMAT);
this.dateToday = newDate;
this.selectedDate$.next(newDate);
this.scheduleScrollToCurrentProgram();
}
/**
* Sets the playing now variable based on the current time
*/
setPlayingNow(): void {
this.items$
.pipe(
map((items) =>
items.find((item) => {
const now = new Date().toISOString();
const start = new Date(item.start).toISOString();
const stop = new Date(item.stop).toISOString();
return now >= start && now <= stop;
})
)
)
.subscribe((playingNow) => {
this.playingNow = playingNow!;
// Always dispatch the action, even when playingNow is null/undefined
// This ensures the store is updated and old EPG data is cleared
this.store.dispatch(
EpgActions.setCurrentEpgProgram({ program: this.playingNow })
);
// Trigger change detection for OnPush strategy
this.cdr.markForCheck();
});
}
/**
* Sets the provided epg program as active and starts to play
* @param program epg program to set
* @param isLive live stream flag
* @param timeshift timeshift flag
*/
setEpgProgram(
program: EpgProgram,
isLive?: boolean,
timeshift?: boolean
): void {
if (isLive) {
this.store.dispatch(EpgActions.resetActiveEpgProgram());
} else {
if (!timeshift) return;
this.store.dispatch(EpgActions.setActiveEpgProgram({ program }));
}
this.playingNow = program;
// Trigger change detection for OnPush strategy
this.cdr.markForCheck();
}
/**
* Calculates the progress percentage for the EPG program
*/
calculateProgress(program: EpgProgram): number {
const now = new Date().getTime();
const start = new Date(program.start).getTime();
const stop = new Date(program.stop).getTime();
const total = stop - start;
const elapsed = now - start;
const progress = Math.min(100, Math.max(0, (elapsed / total) * 100));
return progress;
}
/**
* Check if program is currently playing
*/
isProgramPlaying(program: EpgProgram): boolean {
const isPlaying =
this.timeNow >= program.start && this.timeNow <= program.stop;
return isPlaying;
}
private scheduleScrollToCurrentProgram(): void {
if (this.selectedDate$.value !== moment().format(DATE_FORMAT)) {
return;
}
if (this.scrollScheduled) {
return;
}
this.scrollScheduled = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.scrollScheduled = false;
this.scrollCurrentProgramIntoView();
});
});
}
private scrollCurrentProgramIntoView(): void {
const container = this.programList()?.nativeElement;
const currentProgram = container?.querySelector<HTMLElement>(
'.program-item.current-program'
);
if (!container || !currentProgram) {
return;
}
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
const itemTop = currentProgram.offsetTop;
const itemBottom = itemTop + currentProgram.offsetHeight;
if (itemTop >= viewTop && itemBottom <= viewBottom) {
return;
}
currentProgram.scrollIntoView({
block: 'center',
inline: 'nearest',
});
}
}
@@ -1,115 +0,0 @@
@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>
}
@@ -1,403 +0,0 @@
: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;
}
}
@@ -1,75 +0,0 @@
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();
}
}
@@ -1,77 +0,0 @@
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());
}
}
@@ -1,140 +0,0 @@
<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>
@@ -1,87 +0,0 @@
.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;
}
@@ -1,118 +0,0 @@
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();
}
}
@@ -1 +0,0 @@
<video #videoPlayer id="video-player" autoplay="true" controls="true"></video>
@@ -1,12 +0,0 @@
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
#video-player {
width: 100%;
height: 100%;
}
@@ -1,59 +0,0 @@
import { SimpleChange } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from 'services';
import { HtmlVideoPlayerComponent } from './html-video-player.component';
describe('HtmlVideoPlayerComponent', () => {
let component: HtmlVideoPlayerComponent;
let fixture: ComponentFixture<HtmlVideoPlayerComponent>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let dataService: DataService;
const TEST_CHANNEL = {
id: '1234',
url: 'http://test.ts',
name: 'Test channel',
group: {
title: 'News group',
},
http: {
'user-agent': 'localhost',
},
};
beforeEach(waitForAsync(() => {
const dataServiceMock = {
sendIpcEvent: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
imports: [HtmlVideoPlayerComponent, TranslateModule.forRoot()],
providers: [{ provide: DataService, useValue: dataServiceMock }],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HtmlVideoPlayerComponent);
component = fixture.componentInstance;
dataService = TestBed.inject(DataService);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call play channel function after input changes', () => {
jest.spyOn(component, 'playChannel');
jest.spyOn(global.console, 'error').mockImplementation(() => {
/* empty */
});
component.ngOnChanges({
channel: new SimpleChange(null, TEST_CHANNEL, true),
});
fixture.detectChanges();
expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL);
});
});
@@ -1,218 +0,0 @@
import {
Component,
ElementRef,
EventEmitter,
inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import Hls from 'hls.js';
import mpegts from 'mpegts.js';
import { getExtensionFromUrl } from 'm3u-utils';
import { DataService } from 'services';
import { Channel } from 'shared-interfaces';
/**
* This component contains the implementation of HTML5 based video player
*/
@Component({
selector: 'app-html-video-player',
templateUrl: './html-video-player.component.html',
styleUrls: ['./html-video-player.component.scss'],
standalone: true,
})
export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** Channel to play */
@Input() channel!: Channel;
@Input() volume = 1;
@Input() startTime = 0;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
private readonly dataService = inject(DataService);
/** Video player DOM element */
@ViewChild('videoPlayer', { static: true })
videoPlayer!: ElementRef<HTMLVideoElement>;
/** HLS object */
hls!: Hls;
/** mpegts.js player for raw MPEG-TS streams */
private mpegtsPlayer: mpegts.Player | null = null;
/** Captions/subtitles indicator */
@Input() showCaptions!: boolean;
ngOnInit() {
this.videoPlayer.nativeElement.addEventListener('volumechange', () => {
this.onVolumeChange();
});
this.videoPlayer.nativeElement.addEventListener('loadedmetadata', () => {
if (this.startTime > 0) {
this.videoPlayer.nativeElement.currentTime = this.startTime;
}
});
this.videoPlayer.nativeElement.addEventListener('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.videoPlayer.nativeElement.currentTime,
duration: this.videoPlayer.nativeElement.duration,
});
});
}
/**
* Listen for component input changes
* @param changes component changes
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['channel'] && changes['channel'].currentValue) {
this.playChannel(changes['channel'].currentValue);
}
if (changes['volume']?.currentValue !== undefined) {
console.log(
'Setting HTML5 player volume to:',
changes['volume'].currentValue
);
this.videoPlayer.nativeElement.volume =
changes['volume'].currentValue;
}
}
/**
* Starts to play the given channel
* @param channel given channel object
*/
playChannel(channel: Channel): void {
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
this.mpegtsPlayer.detachMediaElement();
this.mpegtsPlayer.destroy();
this.mpegtsPlayer = null;
}
if (this.hls) this.hls.destroy();
if (channel.url) {
const url = channel.url + (channel.epgParams ?? '');
const extension = getExtensionFromUrl(channel.url);
// Set user agent if specified on channel
if (channel.http?.['user-agent']) {
window.electron?.setUserAgent(
channel.http['user-agent'],
channel.http.referrer
);
}
if (extension === 'ts' && mpegts.isSupported()) {
console.log('Using mpegts.js for TS stream:', channel.name, url);
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
url: url,
});
this.mpegtsPlayer.attachMediaElement(
this.videoPlayer.nativeElement
);
this.mpegtsPlayer.load();
this.handlePlayOperation();
} else if (
extension !== 'mp4' &&
extension !== 'mpv' &&
Hls &&
Hls.isSupported()
) {
console.log('... switching channel to ', channel.name, url);
this.hls = new Hls();
this.hls.attachMedia(this.videoPlayer.nativeElement);
this.hls.loadSource(url);
this.handlePlayOperation();
} else {
console.log('Using native video player...');
this.addSourceToVideo(
this.videoPlayer.nativeElement,
url,
'video/mp4'
);
this.videoPlayer.nativeElement.play();
}
}
}
addSourceToVideo(element: HTMLVideoElement, url: string, type: string) {
const source = document.createElement('source');
source.src = url;
source.type = type;
element.appendChild(source);
}
/**
* Disables text based captions based on the global settings
*/
disableCaptions(): void {
for (
let i = 0;
i < this.videoPlayer.nativeElement.textTracks.length;
i++
) {
this.videoPlayer.nativeElement.textTracks[i].mode = 'hidden';
}
}
/**
* Handles promise based play operation
*/
handlePlayOperation(): void {
const playPromise = this.videoPlayer.nativeElement.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
// Automatic playback started!
if (!this.showCaptions) {
this.disableCaptions();
}
})
.catch(() => {
// Do nothing
});
}
}
/**
* Save volume when user changes it
*/
onVolumeChange(): void {
const currentVolume = this.videoPlayer.nativeElement.volume;
console.log('Volume changed to:', currentVolume);
localStorage.setItem('volume', currentVolume.toString());
}
/**
* Destroy hls instance on component destroy and clean up event listener
*/
ngOnDestroy(): void {
this.videoPlayer.nativeElement.removeEventListener(
'volumechange',
this.onVolumeChange
);
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
this.mpegtsPlayer.detachMediaElement();
this.mpegtsPlayer.destroy();
this.mpegtsPlayer = null;
}
if (this.hls) {
this.hls.destroy();
}
}
}
@@ -28,13 +28,13 @@ export class InfoOverlayComponent implements OnChanges {
finishedDuration!: number;
/** Program start time */
start: any | undefined;
start: string | undefined;
/** Program end time */
stop: any | undefined;
stop: string | undefined;
/** Timeout for the overlay visibility */
private currentTimeout: any;
private currentTimeout: ReturnType<typeof setTimeout> | undefined;
/**
* Calculates the necessary information for the visualization in the overview popup
@@ -67,17 +67,19 @@ export class InfoOverlayComponent implements OnChanges {
* @param start program start time
* @param stop program stop time
*/
setProgramDuration(start: number, stop: number): void {
this.stop = moment(stop, 'YYYYMMDDHHmm ZZ');
this.start = moment(start, 'YYYYMMDDHHmm ZZ');
setProgramDuration(start: string | number, stop: string | number): void {
const stopMoment = moment(stop, 'YYYYMMDDHHmm ZZ');
const startMoment = moment(start, 'YYYYMMDDHHmm ZZ');
this.stop = stopMoment.toISOString();
this.start = startMoment.toISOString();
const timeNow = moment(Date.now());
this.generalDuration = moment
.duration(this.stop.diff(this.start))
.duration(stopMoment.diff(startMoment))
.asMilliseconds();
this.finishedDuration = moment
.duration(this.stop.diff(timeNow))
.duration(stopMoment.diff(timeNow))
.asMilliseconds();
}
@@ -1,226 +0,0 @@
<div id="epg-navigation">
<button
[matTooltip]="'CLOSE' | translate"
mat-icon-button
(click)="close()"
>
<mat-icon>close</mat-icon>
</button>
<button
[matTooltip]="'EPG.PREVIOUS_DAY' | translate"
mat-icon-button
(click)="switchDay('prev')"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div class="today-date">
{{ today() | momentDate: 'MMMM Do, dddd' : 'YYYYMMDD' }}
</div>
<button
[matTooltip]="'EPG.NEXT_DAY' | translate"
mat-icon-button
(click)="switchDay('next')"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button mat-icon-button (click)="zoomOut()" [disabled]="hourWidth() <= 50" matTooltip="Zoom out">
<mat-icon>remove</mat-icon>
</button>
<button mat-icon-button (click)="zoomIn()" [disabled]="hourWidth() >= 800" matTooltip="Zoom in">
<mat-icon>add</mat-icon>
</button>
<div class="search-container" [class.expanded]="isSearchExpanded()">
@if (isSearchExpanded()) {
<div class="channel-search">
<mat-icon class="search-icon">search</mat-icon>
<input
type="text"
[value]="channelFilter()"
(input)="onFilterInput($event)"
placeholder="Filter channels..."
autocomplete="off"
#searchInput
/>
@if (channelFilter()) {
<button class="clear-btn" (click)="clearFilter()">
<mat-icon>clear</mat-icon>
</button>
}
</div>
<span class="filter-count">{{ filteredChannels().length }} / {{ channels().length }}</span>
}
<button
mat-icon-button
(click)="toggleSearch()"
[matTooltip]="isSearchExpanded() ? 'Close filter' : 'Filter channels'"
>
<mat-icon>{{ isSearchExpanded() ? 'filter_list_off' : 'filter_list' }}</mat-icon>
</button>
</div>
<!-- Program Search -->
<div class="program-search-container" [class.open]="isProgramSearchOpen()">
@if (isProgramSearchOpen()) {
<div class="program-search-input">
<mat-icon class="search-icon">manage_search</mat-icon>
<input
type="text"
[value]="programSearchQuery()"
(input)="onProgramSearchInput($event)"
placeholder="Search programs..."
autocomplete="off"
#programSearchInput
/>
@if (programSearchQuery()) {
<button class="clear-btn" (click)="clearProgramSearch()">
<mat-icon>clear</mat-icon>
</button>
}
@if (isSearchingPrograms()) {
<div class="search-spinner"></div>
}
</div>
@if (programSearchResults().length > 0) {
<div class="program-search-results">
@for (program of programSearchResults(); track $index) {
<button class="search-result-item" (click)="showProgramDetails(program)">
<div class="result-info">
<span class="result-title">{{ program.title }}</span>
<span class="result-meta">
<span class="result-time">{{ formatProgramTime(program) }}</span>
<span class="result-date">{{ formatProgramDate(program) }}</span>
</span>
</div>
<mat-icon class="result-arrow">arrow_forward</mat-icon>
</button>
}
</div>
}
@if (programSearchQuery().length >= 2 && programSearchResults().length === 0 && !isSearchingPrograms()) {
<div class="program-search-empty">
<mat-icon>search_off</mat-icon>
<span>No programs found</span>
</div>
}
}
<button
mat-icon-button
(click)="toggleProgramSearch()"
[matTooltip]="isProgramSearchOpen() ? 'Close program search' : 'Search programs'"
>
<mat-icon>{{ isProgramSearchOpen() ? 'close' : 'manage_search' }}</mat-icon>
</button>
</div>
</div>
<div class="parent" #epgContainer (scroll)="onScroll($event)">
<svg id="channels-column" [attr.height]="filteredChannels().length * barHeight + barHeight + 50">
@for (item of filteredChannels(); track item.id; let i = $index) {
<g>
<!-- channel name -->
<g class="channel" [class.active]="item.id === activeChannelId">
<rect
width="100"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
stroke="black"
/>
<foreignObject
width="100"
[attr.height]="barHeight"
[attr.y]="barHeight * i + barHeight"
>
<div class="channel-name">
@if (getChannelIcon(item)) {
<img
[src]="getChannelIcon(item)"
[alt]="getChannelName(item)"
style="height: 20px; width: 20px; margin-right: 8px;"
>
}
<span>{{ getChannelName(item) }}</span>
</div>
</foreignObject>
</g>
</g>
}
</svg>
<div id="epg-container" [style.height.px]="filteredChannels().length * barHeight + barHeight + 50">
<svg [attr.width]="hourWidth() * 24" [attr.height]="filteredChannels().length * barHeight + barHeight + 50" id="epg-svg">
<!-- time headline -->
@for (hour of timeHeader; track hour) {
<g>
<rect
[matTooltip]="hour + ':00'"
[attr.width]="hourWidth()"
[attr.height]="barHeight"
[attr.x]="hour * hourWidth()"
fill="#000"
></rect>
<text
[attr.x]="hour * hourWidth()"
y="10"
font-size="14"
fill="white"
transform="translate(20,20)"
>
{{ hour }}:00
</text>
<!-- vertical hour separator line -->
<line
[attr.x1]="hour * hourWidth()"
[attr.y1]="barHeight"
[attr.x2]="hour * hourWidth()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
class="hour-separator"
></line>
</g>
}
<!-- epg channels with programs -->
@for (item of filteredChannels(); track item.id; let i = $index) {
<g [class.active-row]="item.id === activeChannelId">
@for (program of item.programs; track trackByProgram($index, program)) {
<g
[attr.transform]="'translate(' + program.startPosition + ',' + (barHeight * i + barHeight) + ')'"
class="program"
[class.highlighted]="highlightedProgramKey() === getProgramKey(program)"
(click)="showDescription(program)"
>
<rect
[attr.width]="program.width"
[attr.height]="barHeight"
[matTooltip]="program.title"
></rect>
<foreignObject
[attr.width]="program.width"
[attr.height]="barHeight"
>
<div class="program-title">{{ program.title }}</div>
</foreignObject>
</g>
}
</g>
}
<!-- current time line -->
<line
[attr.x1]="currentTimeLine()"
y1="0"
[attr.x2]="currentTimeLine()"
[attr.y2]="filteredChannels().length * barHeight + barHeight"
stroke="red"
stroke-width="2"
></line>
</svg>
</div>
</div>
<!-- Empty state when no channels match filter -->
@if (filteredChannels().length === 0 && channelFilter()) {
<div class="no-results">
<mat-icon>search_off</mat-icon>
<p>No channels match "{{ channelFilter() }}"</p>
<button mat-button (click)="clearFilter()">Clear filter</button>
</div>
}
@@ -1,850 +0,0 @@
// Color palette - Broadcast control room aesthetic
$bg-deep: #0a0a0f;
$bg-surface: #12121a;
$bg-elevated: #1a1a24;
$bg-hover: #24243a;
$border-subtle: rgba(255, 255, 255, 0.06);
$border-medium: rgba(255, 255, 255, 0.12);
// Accent colors - TV broadcast vibes
$accent-primary: #00d4aa; // Cyan/teal for time
$accent-secondary: #8b5cf6; // Purple for programs
$accent-tertiary: #f472b6; // Pink for highlights
$accent-warning: #fbbf24;
// Text colors
$text-primary: #f8fafc;
$text-secondary: rgba(248, 250, 252, 0.7);
$text-muted: rgba(248, 250, 252, 0.4);
// Gradients
$gradient-program: linear-gradient(
135deg,
rgba(139, 92, 246, 0.15) 0%,
rgba(139, 92, 246, 0.05) 100%
);
$gradient-program-hover: linear-gradient(
135deg,
rgba(139, 92, 246, 0.3) 0%,
rgba(139, 92, 246, 0.15) 100%
);
$gradient-header: linear-gradient(
180deg,
rgba(0, 212, 170, 0.08) 0%,
transparent 100%
);
// Base container
.parent {
display: flex;
background: $bg-deep;
color: $text-primary;
font-family: Roboto, 'Helvetica Neue', sans-serif;
position: relative;
overflow-y: auto;
height: calc(100vh - 56px);
// Custom scrollbar for vertical scroll
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: $bg-surface;
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.4);
border-radius: 4px;
&:hover {
background: rgba(139, 92, 246, 0.6);
}
}
}
// Navigation toolbar - Frosted glass effect
#epg-navigation {
display: flex;
align-items: center;
padding: 12px 16px 12px 80px; // Left padding for macOS traffic lights
background: rgba(18, 18, 26, 0.85);
backdrop-filter: blur(20px);
border-bottom: 1px solid $border-medium;
gap: 8px;
position: relative;
z-index: 100;
-webkit-app-region: drag;
// Subtle glow at bottom
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(0, 212, 170, 0.3) 20%,
rgba(139, 92, 246, 0.3) 80%,
transparent 100%
);
}
// Button styling - exclude from drag region
button {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
letter-spacing: 0.02em;
transition: all 0.2s ease;
-webkit-app-region: no-drag;
&:not([disabled]):hover {
background: rgba(139, 92, 246, 0.15);
}
&[disabled] {
opacity: 0.3;
}
mat-icon {
font-size: 20px;
}
}
}
// Search container - exclude from drag region
.search-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
-webkit-app-region: no-drag;
.channel-search {
display: flex;
align-items: center;
gap: 8px;
width: 200px;
height: 36px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid $border-subtle;
border-radius: 8px;
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(255, 255, 255, 0.08);
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $text-muted;
flex-shrink: 0;
}
input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 13px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
color: $text-primary;
min-width: 0;
&::placeholder {
color: $text-muted;
}
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: $text-muted;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: $text-primary;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
}
.filter-count {
font-size: 11px;
color: $text-muted;
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
}
&.expanded {
.channel-search {
animation: slideIn 0.2s ease-out;
}
}
}
@keyframes slideIn {
from {
opacity: 0;
width: 0;
transform: translateX(20px);
}
to {
opacity: 1;
width: 200px;
transform: translateX(0);
}
}
// Program search container
.program-search-container {
display: flex;
align-items: center;
gap: 8px;
position: relative;
-webkit-app-region: no-drag;
.program-search-input {
display: flex;
align-items: center;
gap: 8px;
width: 220px;
height: 36px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid $border-subtle;
border-radius: 8px;
transition: all 0.2s ease;
&:focus-within {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(255, 255, 255, 0.08);
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: $accent-secondary;
flex-shrink: 0;
}
input {
flex: 1;
background: transparent;
border: none;
outline: none;
font-size: 13px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
color: $text-primary;
min-width: 0;
&::placeholder {
color: $text-muted;
}
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
cursor: pointer;
color: $text-muted;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: $text-primary;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
.search-spinner {
width: 16px;
height: 16px;
border: 2px solid $border-subtle;
border-top-color: $accent-secondary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
}
.program-search-results {
position: fixed;
top: 60px;
right: 16px;
width: 320px;
max-height: 400px;
overflow-y: auto;
background: $bg-elevated;
border: 1px solid $border-medium;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10000;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 3px;
}
.search-result-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 1px solid $border-subtle;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: $bg-hover;
.result-arrow {
opacity: 1;
transform: translateX(0);
}
}
.result-info {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
}
.result-title {
font-size: 13px;
font-weight: 500;
color: $text-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-meta {
display: flex;
gap: 8px;
font-size: 11px;
color: $text-muted;
}
.result-time {
color: $accent-primary;
font-family: 'JetBrains Mono', monospace;
}
.result-date {
color: $text-muted;
}
.result-arrow {
font-size: 18px;
width: 18px;
height: 18px;
color: $accent-secondary;
opacity: 0;
transform: translateX(-4px);
transition: all 0.15s ease;
}
}
}
.program-search-empty {
position: fixed;
top: 60px;
right: 16px;
width: 220px;
padding: 24px 16px;
background: $bg-elevated;
border: 1px solid $border-medium;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: $text-muted;
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
opacity: 0.5;
}
span {
font-size: 12px;
}
}
&.open {
.program-search-input {
animation: slideIn 0.2s ease-out;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes highlightPulse {
0%, 100% {
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6));
}
50% {
filter: drop-shadow(0 0 20px rgba(0, 212, 170, 0.9))
drop-shadow(0 0 40px rgba(0, 212, 170, 0.4));
}
}
// No results state
.no-results {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: $text-muted;
text-align: center;
z-index: 100;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 14px;
}
button {
color: $accent-primary;
}
}
// Today's date display
.today-date {
flex: 1;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 500;
color: $accent-primary;
text-align: center;
padding: 0 24px;
letter-spacing: 0.05em;
text-transform: uppercase;
// Glowing text effect
text-shadow: 0 0 20px rgba(0, 212, 170, 0.3);
}
// Channel column
#channels-column {
width: 140px;
flex: none;
border-right: 1px solid $border-medium;
background: $bg-surface;
position: relative;
z-index: 10;
min-height: calc(100vh - 6px);
// Subtle shadow for depth
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3);
.channel {
cursor: pointer;
transition: all 0.15s ease;
rect {
fill: $bg-surface;
stroke: $border-subtle;
transition: fill 0.15s ease;
}
&:hover rect {
fill: $bg-elevated;
}
&.active rect {
fill: rgba(139, 92, 246, 0.2);
stroke: rgba(139, 92, 246, 0.4);
}
&.active .channel-name {
color: $accent-secondary;
font-weight: 600;
}
}
.channel-name {
color: $text-primary;
padding: 8px 12px;
font-size: 11px;
font-weight: 500;
display: flex;
align-items: center;
height: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.02em;
img {
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
padding: 2px;
}
span {
overflow: hidden;
text-overflow: ellipsis;
}
}
image {
object-fit: contain;
background: $bg-elevated;
}
}
// EPG container
#epg-container {
width: calc(100vw - 140px);
overflow-x: scroll;
overflow-y: clip;
background: $bg-deep;
min-height: calc(100vh - 6px);
// Custom scrollbar
&::-webkit-scrollbar {
height: 8px;
}
&::-webkit-scrollbar-track {
background: $bg-surface;
}
&::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.4);
border-radius: 4px;
&:hover {
background: rgba(139, 92, 246, 0.6);
}
}
}
// EPG SVG container
#epg-svg {
// Time header styling
g > rect[fill='#000'] {
fill: $bg-surface !important;
}
g > text {
font-family: 'JetBrains Mono', monospace !important;
font-size: 11px !important;
font-weight: 500;
fill: $text-secondary !important;
letter-spacing: 0.1em;
}
// Hour separator vertical lines
.hour-separator {
stroke: $border-subtle;
stroke-width: 1px;
pointer-events: none;
}
// Program blocks
.program {
cursor: pointer;
rect {
fill: $bg-elevated;
stroke: $border-subtle;
rx: 4;
ry: 4;
transition: all 0.15s ease;
}
&:hover rect {
fill: $bg-hover;
stroke: rgba(139, 92, 246, 0.4);
filter: drop-shadow(0 0 8px rgba(139, 92, 246, 0.2));
}
// Highlighted program (from search)
&.highlighted rect {
fill: rgba(0, 212, 170, 0.25);
stroke: $accent-primary;
stroke-width: 2px;
animation: highlightPulse 1s ease-in-out 3;
}
&.highlighted .program-title {
color: $accent-primary;
font-weight: 600;
}
}
// Active channel row highlighting
.active-row .program rect {
fill: rgba(139, 92, 246, 0.15);
stroke: rgba(139, 92, 246, 0.3);
}
.active-row .program:hover rect {
fill: rgba(139, 92, 246, 0.25);
}
.program-title {
color: $text-primary;
padding: 6px 10px;
font-size: 11px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
letter-spacing: 0.01em;
line-height: 1.4;
// Subtle gradient text for depth
display: flex;
align-items: center;
height: 100%;
}
// Current time line - glowing effect
line[stroke='red'] {
stroke: $accent-primary !important;
stroke-width: 2px;
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6))
drop-shadow(0 0 16px rgba(0, 212, 170, 0.3));
// Animated pulse
animation: timePulse 2s ease-in-out infinite;
}
}
// Pulse animation for time line
@keyframes timePulse {
0%,
100% {
opacity: 1;
filter: drop-shadow(0 0 8px rgba(0, 212, 170, 0.6))
drop-shadow(0 0 16px rgba(0, 212, 170, 0.3));
}
50% {
opacity: 0.8;
filter: drop-shadow(0 0 12px rgba(0, 212, 170, 0.8))
drop-shadow(0 0 24px rgba(0, 212, 170, 0.4));
}
}
// Legacy classes cleanup
.program-item {
cursor: pointer;
}
rect {
stroke: $border-subtle;
cursor: pointer;
}
.channel > rect {
stroke: $border-subtle;
}
.program-name,
.channel-name {
-webkit-line-clamp: 2;
height: 32px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
padding: 6px 8px;
}
#current-time-line {
stroke-width: 2px;
stroke: $accent-primary;
}
// Dark theme override (already dark, but ensure consistency)
::ng-deep {
.dark-theme {
#epg-navigation {
background: rgba(18, 18, 26, 0.85);
}
}
// Light theme adjustments
.light-theme {
#epg-navigation {
background: rgba(255, 255, 255, 0.9);
border-bottom-color: rgba(0, 0, 0, 0.1);
.today-date {
color: #059669;
text-shadow: none;
}
}
.parent {
background: #f8fafc;
&::before {
background-image:
linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px),
linear-gradient(
90deg,
rgba(0, 0, 0, 0.03) 1px,
transparent 1px
);
}
}
#channels-column {
background: #ffffff;
border-right-color: rgba(0, 0, 0, 0.1);
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.05);
.channel rect {
fill: #ffffff;
stroke: rgba(0, 0, 0, 0.08);
}
.channel:hover rect {
fill: #f1f5f9;
}
.channel-name {
color: #1e293b;
}
}
#epg-container {
background: #f8fafc;
}
#epg-svg {
g > rect[fill='#000'] {
fill: #e2e8f0 !important;
}
g > text {
fill: #64748b !important;
}
.program {
rect {
fill: #ffffff;
stroke: rgba(0, 0, 0, 0.08);
}
&:hover rect {
fill: #f1f5f9;
stroke: rgba(139, 92, 246, 0.4);
}
}
.program-title {
color: #1e293b;
}
line[stroke='red'] {
stroke: #059669 !important;
filter: drop-shadow(0 0 6px rgba(5, 150, 105, 0.4));
}
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
#epg-navigation {
padding-left: 70px; // Slightly reduced for smaller screens
}
#channels-column {
width: 100px;
.channel-name {
font-size: 10px;
padding: 6px 8px;
}
}
#epg-container {
width: calc(100vw - 100px);
}
.today-date {
font-size: 12px;
padding: 0 12px;
}
}
@@ -1,439 +0,0 @@
import { OverlayRef } from '@angular/cdk/overlay';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
inject,
Input,
OnDestroy,
OnInit,
signal,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { MomentDatePipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns';
import { Observable, Subscription } from 'rxjs';
import { Channel, EpgChannel, EpgProgram } from 'shared-interfaces';
import { EpgItemDescriptionComponent } from '../epg-list/epg-item-description/epg-item-description.component';
import { COMPONENT_OVERLAY_REF } from './overlay-ref.token';
interface EnrichedProgram extends EpgProgram {
startDate: Date;
stopDate: Date;
startPosition: number;
width: number;
}
interface EnrichedChannel extends EpgChannel {
programs: EnrichedProgram[];
}
@Component({
imports: [
MatButtonModule,
MatIcon,
MatTooltip,
MomentDatePipe,
TranslatePipe
],
selector: 'app-multi-epg-container',
templateUrl: './multi-epg-container.component.html',
styleUrls: ['./multi-epg-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MultiEpgContainerComponent
implements OnInit, AfterViewInit, OnDestroy
{
readonly epgContainer = viewChild.required<ElementRef>('epgContainer');
@Input() set playlistChannels(value: Observable<Channel[]>) {
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
if (value) {
this.playlistChannelsSubscription = value.subscribe(() => {
this.channelsLowerRange = 0;
this.originalEpgData.set([]);
this.isLastPage.set(false);
this.initializeVisibleChannels();
this.requestPrograms();
});
}
}
@Input() activeChannelId: string | null = null;
// Signals
readonly hourWidth = signal(150);
readonly today = signal(format(new Date(), 'yyyyMMdd'));
readonly originalEpgData = signal<any[]>([]);
readonly isLoading = signal(false);
readonly isLastPage = signal(false);
readonly channelFilter = signal('');
readonly isSearchExpanded = signal(false);
// Program search signals
readonly isProgramSearchOpen = signal(false);
readonly programSearchQuery = signal('');
readonly programSearchResults = signal<any[]>([]);
readonly isSearchingPrograms = signal(false);
readonly highlightedProgramKey = signal<string | null>(null);
private searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Computed signal for enriched channels - automatically updates when dependencies change
readonly channels = computed(() => this.enrichProgramData());
// Computed signal for filtered channels based on search term
readonly filteredChannels = computed(() => {
const filter = this.channelFilter().toLowerCase().trim();
const allChannels = this.channels();
if (!filter) {
return allChannels;
}
return allChannels.filter(channel => {
const name = this.getChannelName(channel).toLowerCase();
return name.includes(filter);
});
});
// Computed signal for current time line position
readonly currentTimeLine = computed(() => {
const now = new Date();
return (now.getHours() + now.getMinutes() / 60) * this.hourWidth();
});
// Constants
readonly timeHeader = Array.from({ length: 24 }, (_, i) => i);
readonly barHeight = 50;
// Pagination state
visibleChannels = 20;
channelsLowerRange = 0;
channelsUpperRange = this.visibleChannels;
totalChannels = 0;
private dateCache = new Map<string, Date>();
private interval!: ReturnType<typeof setInterval>;
private playlistChannelsSubscription?: Subscription;
private readonly dialog = inject(MatDialog);
private readonly overlayRef = inject<OverlayRef>(COMPONENT_OVERLAY_REF);
ngOnInit() {
// Update current time line every minute
this.interval = setInterval(() => {
// Force recomputation by updating hourWidth to same value
// This triggers the computed signal to recalculate
this.hourWidth.update(v => v);
}, 60000);
}
ngAfterViewInit(): void {
this.initializeVisibleChannels();
this.scrollToCurrentTime();
}
private scrollToCurrentTime(): void {
const timeNow = new Date();
const scrollPosition =
(timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth();
requestAnimationFrame(() => {
const container = document.getElementById('epg-container');
if (container) {
container.scrollTo(
scrollPosition < 1000 ? 0 : scrollPosition - 150,
0
);
}
});
}
private initializeVisibleChannels(): void {
const epgContainer = this.epgContainer();
if (epgContainer) {
const containerHeight = epgContainer.nativeElement.offsetHeight;
const calculatedVisibleChannels = Math.floor(
(containerHeight - this.barHeight) / this.barHeight
);
this.visibleChannels = Math.max(
10,
Math.min(calculatedVisibleChannels, 20)
);
this.channelsUpperRange = this.visibleChannels;
}
}
ngOnDestroy(): void {
clearInterval(this.interval);
if (this.playlistChannelsSubscription) {
this.playlistChannelsSubscription.unsubscribe();
}
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
}
trackByIndex(index: number): number {
return index;
}
trackByProgram(_: number, program: EnrichedProgram): string {
return `${program.start}|${program?.title?.toString() ?? ''}`;
}
async requestPrograms(): Promise<void> {
if (!window.electron) {
console.warn('Multi-EPG not available: Electron not detected');
return;
}
if (this.isLoading() || this.isLastPage()) {
return;
}
this.isLoading.set(true);
try {
const response = await window.electron.getEpgChannelsByRange(
this.channelsLowerRange,
this.visibleChannels
);
if (response && Array.isArray(response)) {
// Append new data to existing data
this.originalEpgData.update(data => [...data, ...response]);
// Update isLastPage based on the number of channels received
this.isLastPage.set(response.length < this.visibleChannels);
// Update range for next fetch
this.channelsLowerRange += response.length;
}
} catch (error) {
console.error('Error fetching EPG data:', error);
} finally {
this.isLoading.set(false);
}
}
onScroll(event: Event): void {
const target = event.target as HTMLElement;
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;
// Load more when user scrolls to within 200px of the bottom
if (scrollHeight - scrollTop - clientHeight < 200) {
this.requestPrograms();
}
}
private getCachedDate(dateStr: string): Date {
let date = this.dateCache.get(dateStr);
if (!date) {
date = new Date(dateStr);
this.dateCache.set(dateStr, date);
}
return date;
}
private enrichProgramData(): EnrichedChannel[] {
const hourWidthValue = this.hourWidth();
const todayValue = this.today();
const data = this.originalEpgData();
return data.map((channel) => {
const filteredPrograms = (channel.programs || [])
.filter((item: EpgProgram) => {
const itemDate = format(
this.getCachedDate(item.start),
'yyyyMMdd'
);
return itemDate === todayValue;
})
.map((program: EpgProgram) => {
const startDate = this.getCachedDate(program.start);
const stopDate = this.getCachedDate(program.stop);
const startPosition =
(startDate.getHours() + startDate.getMinutes() / 60) *
hourWidthValue;
const duration = differenceInMinutes(stopDate, startDate);
const width = (duration * hourWidthValue) / 60;
return {
...program,
startDate,
stopDate,
startPosition,
width,
};
});
return {
...channel,
programs: filteredPrograms,
};
});
}
/**
* Get display name from EpgChannel
*/
getChannelName(channel: EpgChannel): string {
if (typeof channel.displayName === 'string') {
return channel.displayName;
}
if (Array.isArray(channel.displayName) && channel.displayName.length > 0) {
return channel.displayName[0].value;
}
return '';
}
/**
* Get icon from EpgChannel
*/
getChannelIcon(channel: EpgChannel): string {
if ((channel as any).iconUrl) {
return (channel as any).iconUrl;
}
if (channel.icon && channel.icon.length > 0) {
return channel.icon[0].src;
}
return '';
}
zoomIn(): void {
if (this.hourWidth() >= 800) return;
this.hourWidth.update(v => v + 50);
}
zoomOut(): void {
if (this.hourWidth() <= 50) return;
this.hourWidth.update(v => v - 50);
}
toggleSearch(): void {
this.isSearchExpanded.update(v => !v);
if (!this.isSearchExpanded()) {
this.channelFilter.set('');
}
}
clearFilter(): void {
this.channelFilter.set('');
}
onFilterInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.channelFilter.set(input.value);
}
switchDay(direction: 'prev' | 'next'): void {
const currentDate = parse(this.today(), 'yyyyMMdd', new Date());
this.today.set(
direction === 'prev'
? format(subDays(currentDate, 1), 'yyyyMMdd')
: format(addDays(currentDate, 1), 'yyyyMMdd')
);
}
showDescription(program: EpgProgram): void {
this.dialog.open(EpgItemDescriptionComponent, {
data: program,
});
}
// Program search methods
toggleProgramSearch(): void {
this.isProgramSearchOpen.update(v => !v);
if (!this.isProgramSearchOpen()) {
this.programSearchQuery.set('');
this.programSearchResults.set([]);
}
}
onProgramSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
const query = input.value.trim();
this.programSearchQuery.set(query);
// Clear previous debounce timer
if (this.searchDebounceTimer) {
clearTimeout(this.searchDebounceTimer);
}
if (query.length < 2) {
this.programSearchResults.set([]);
this.isSearchingPrograms.set(false);
return;
}
// Debounce search by 500ms - show spinner only when search actually starts
this.searchDebounceTimer = setTimeout(async () => {
this.isSearchingPrograms.set(true);
try {
const results = await window.electron.searchEpgPrograms(query, 20);
this.programSearchResults.set(results || []);
} catch (error) {
console.error('Error searching programs:', error);
this.programSearchResults.set([]);
} finally {
this.isSearchingPrograms.set(false);
}
}, 500);
}
clearProgramSearch(): void {
this.programSearchQuery.set('');
this.programSearchResults.set([]);
}
showProgramDetails(program: any): void {
// Get channel id (DB returns channel_id, EPG uses channel)
const channelId = program.channel_id || program.channel;
// Find channel name
const channel = this.channels().find(ch => ch.id === channelId);
const channelName = channel ? this.getChannelName(channel) : null;
// Open the program details dialog with channel name
this.dialog.open(EpgItemDescriptionComponent, {
data: { ...program, channelName },
});
}
getProgramKey(program: any): string {
const channelId = program.channel_id || program.channel;
return `${channelId}|${program.start}`;
}
formatProgramTime(program: EpgProgram): string {
const start = new Date(program.start);
const stop = new Date(program.stop);
return `${format(start, 'HH:mm')} - ${format(stop, 'HH:mm')}`;
}
formatProgramDate(program: EpgProgram): string {
const start = new Date(program.start);
return format(start, 'MMM d');
}
close() {
this.overlayRef.detach();
}
}
@@ -1,9 +0,0 @@
import { OverlayRef } from '@angular/cdk/overlay';
import { InjectionToken } from '@angular/core';
/**
* Injection token for providing OverlayRef to dynamically created overlay components
*/
export const COMPONENT_OVERLAY_REF = new InjectionToken<OverlayRef>(
'COMPONENT_OVERLAY_REF'
);
@@ -1,140 +0,0 @@
<div
class="playlist-switcher-trigger"
[class.is-open]="isMenuOpen()"
matRipple
[matRippleDisabled]="false"
[matMenuTriggerFor]="playlistMenu"
#menuTrigger="matMenuTrigger"
#triggerElement
(menuOpened)="onMenuOpened()"
(menuClosed)="onMenuClosed()"
>
<div class="trigger-icon-container">
<mat-icon class="trigger-playlist-icon">playlist_play</mat-icon>
</div>
<div class="playlist-info">
<div class="name">{{ displayTitle() }}</div>
<div class="channels-count">{{ subtitle() }}</div>
</div>
<mat-icon class="dropdown-icon" [class.rotated]="isMenuOpen()">
expand_more
</mat-icon>
</div>
<mat-menu
#playlistMenu="matMenu"
panelClass="playlist-switcher-menu"
hasBackdrop
backdropClass="playlist-switcher-menu-backdrop"
overlapTrigger="false"
xPosition="after"
yPosition="below"
>
<div class="menu-header">
<mat-icon>playlist_play</mat-icon>
<span>{{ 'HOME.PLAYLISTS.MY_PLAYLISTS' | translate }}</span>
</div>
<div class="search-container" (click)="$event.stopPropagation()">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="text"
class="search-input"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
[ngModel]="searchQuery()"
(ngModelChange)="setSearchQuery($event)"
/>
@if (searchQuery()) {
<button
mat-icon-button
class="clear-btn"
(click)="setSearchQuery('')"
>
<mat-icon>close</mat-icon>
</button>
}
</div>
<div class="type-filters" (click)="$event.stopPropagation()">
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('m3u')"
(click)="togglePlaylistTypeFilter('m3u', $event)"
>
M3U
</button>
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('stalker')"
(click)="togglePlaylistTypeFilter('stalker', $event)"
>
Stalker
</button>
<button
type="button"
class="type-filter-chip"
[class.selected]="isTypeFilterSelected('xtream')"
(click)="togglePlaylistTypeFilter('xtream', $event)"
>
Xtream
</button>
</div>
<div class="menu-content">
@for (playlist of filteredPlaylists(); track playlist._id) {
<div
class="playlist-item"
[class.selected]="isSelected(playlist)"
(click)="selectPlaylist(playlist)"
>
<!-- @if (isSelected(playlist)) {
<mat-icon class="selected-indicator">check</mat-icon>
} @else {
<div class="selected-indicator-placeholder"></div>
} -->
<div class="playlist-icon-container">
<mat-icon class="playlist-type-icon">
{{ getPlaylistIcon(playlist) }}
</mat-icon>
@if (playlist.serverUrl) {
<span
class="status-dot"
[class]="getStatusClass(playlist._id)"
></span>
}
@if (
playlist.autoRefresh &&
!playlist.serverUrl &&
!playlist.macAddress
) {
<mat-icon class="auto-refresh-indicator"
>autorenew</mat-icon
>
}
</div>
<div class="playlist-details">
<span class="playlist-title">
{{ playlist.title || playlist.filename }}
</span>
<span class="playlist-meta">
{{ getPlaylistTypeLabel(playlist) }}
@if (playlist.updateDate) {
<span class="separator">|</span>
{{ playlist.updateDate | date: 'MMM d, HH:mm' }}
}
</span>
</div>
</div>
} @empty {
<div class="empty-state">
<mat-icon>playlist_add</mat-icon>
<span>{{ 'HOME.PLAYLISTS.NO_PLAYLISTS' | translate }}</span>
</div>
}
</div>
</mat-menu>
@@ -1,386 +0,0 @@
.playlist-switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
app-region: no-drag;
&:hover {
background: rgba(255, 255, 255, 0.13);
}
&:active {
transform: scale(0.98);
}
}
.trigger-icon-container {
width: 30px;
height: 30px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.trigger-playlist-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
.playlist-info {
flex: 1;
min-width: 0;
max-width: 240px;
.name {
font-size: 0.95em;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
letter-spacing: 0.01em;
}
.channels-count {
font-size: 0.74em;
line-height: 1.3;
letter-spacing: 0.02em;
color: var(--mat-sys-on-surface-variant);
}
}
.dropdown-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
margin-left: auto;
&.rotated {
transform: rotate(180deg);
}
}
::ng-deep .playlist-switcher-menu.mat-mdc-menu-panel {
--mat-menu-container-color: var(--mat-sys-surface-container-high);
width: min(
var(--playlist-switcher-overlay-width, 320px),
calc(100vw - 24px)
) !important;
min-width: min(
var(--playlist-switcher-overlay-width, 320px),
calc(100vw - 24px)
) !important;
max-width: min(
var(--playlist-switcher-overlay-width, 320px),
calc(100vw - 24px)
) !important;
margin-top: -1px !important;
border-radius: 0 0 12px 12px !important;
border: 1px solid var(--mat-sys-outline) !important;
border-top: 0 !important;
overflow: hidden !important;
background: var(--mat-menu-container-color) !important;
.mat-mdc-menu-content {
padding: 0 !important;
background: var(--mat-menu-container-color) !important;
}
}
.menu-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px 10px;
font-size: 0.85em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 0 8px 4px;
background: rgba(255, 255, 255, 0.06);
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.search-container:focus-within {
border-color: var(--mat-sys-primary);
background: var(--mat-sys-surface-container-highest);
}
.search-container .search-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
.search-container .search-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.86em;
font-weight: 500;
line-height: 1.4;
color: inherit;
min-width: 0;
}
.search-container .search-input::placeholder {
color: var(--mat-sys-on-surface-variant);
}
.search-container .clear-btn {
width: 24px;
height: 24px;
line-height: 24px;
padding: 0;
}
.search-container .clear-btn mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
.type-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 8px 8px;
margin-top: 6px;
}
.type-filter-chip {
height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid
color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
background: color-mix(
in srgb,
var(--mat-sys-surface-container-high) 78%,
transparent
);
color: var(--mat-sys-on-surface-variant);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1;
cursor: pointer;
transition: all 0.16s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
&.selected {
border-color: var(--app-selection-border, var(--app-selection-color));
background: var(
--app-selection-surface-strong,
var(--app-selection-surface)
);
color: var(--app-selection-color, var(--mat-sys-primary));
/* box-shadow:
inset 0 0 0 1px
color-mix(in srgb, var(--app-selection-color) 18%, transparent),
0 0 0 1px
color-mix(in srgb, var(--app-selection-color) 12%, transparent),
0 0 10px var(--app-selection-glow, transparent); */
}
&:focus-visible {
outline: 2px solid var(--app-selection-border, var(--mat-sys-primary));
outline-offset: 1px;
}
}
.menu-content {
max-height: 360px;
overflow-y: auto;
padding: 8px 0;
}
.playlist-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 6px 12px;
min-height: 52px;
transition: background-color 0.15s ease;
position: relative;
cursor: pointer;
}
.playlist-item.selected {
background: rgba(139, 92, 246, 0.1);
.playlist-title,
.playlist-meta {
color: #a78bfa;
}
}
.playlist-item:hover:not(.selected) {
background: var(--mat-sys-surface-container);
}
.playlist-icon-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.playlist-type-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
}
.status-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
right: -4px;
bottom: -4px;
border: 2px solid var(--mat-menu-container-color, #2a2a2e);
}
.status-active {
background-color: #4caf50;
box-shadow: 0 0 6px rgba(76, 175, 80, 0.4);
}
.status-inactive {
background-color: #f44336;
box-shadow: 0 0 6px rgba(244, 67, 54, 0.4);
}
.status-expired {
background-color: #ff9800;
box-shadow: 0 0 6px rgba(255, 152, 0, 0.4);
}
.status-unavailable {
background-color: #666666;
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 12px !important;
width: 14px !important;
height: 14px !important;
color: var(--mat-sys-primary);
background-color: var(--mat-menu-container-color, var(--mat-sys-surface));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.playlist-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.playlist-title {
font-size: 0.92em;
font-weight: 600;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist-meta {
font-size: 0.73em;
line-height: 1.25;
letter-spacing: 0.01em;
color: var(--mat-sys-on-surface-variant);
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.separator {
color: var(--mat-sys-on-surface-variant);
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
}
span {
font-size: 0.85em;
}
}
@media only screen and (max-width: 480px) {
.playlist-info {
max-width: 140px;
.name {
font-size: 0.9em;
}
}
.playlist-icon-container {
display: none;
}
}
@@ -1,850 +0,0 @@
import { DatePipe, DOCUMENT } from '@angular/common';
import {
Component,
computed,
DestroyRef,
ElementRef,
effect,
inject,
input,
output,
signal,
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatRippleModule } from '@angular/material/core';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistActions,
selectActivePlaylistId,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { filter } from 'rxjs';
import { PortalStatus, PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
type PlaylistRouteProvider = 'playlists' | 'xtreams' | 'stalker';
type PlaylistSectionProvider = Exclude<PlaylistRouteProvider, 'playlists'>;
type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream';
interface PlaylistRouteContext {
inWorkspace: boolean;
isWorkspaceDashboard: boolean;
provider: PlaylistRouteProvider | null;
playlistId: string | null;
section: string | null;
}
const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections';
const LAST_ACTIVE_PLAYLIST_STORAGE_KEY =
'playlist-switcher:last-active-playlist-id';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
const DEFAULT_PLAYLIST_TYPE_FILTERS: Record<PlaylistFilterType, boolean> = {
m3u: true,
stalker: true,
xtream: true,
};
interface PlaylistSectionMemory {
providers: Partial<Record<PlaylistSectionProvider, string>>;
playlists: Record<
string,
{
provider: PlaylistSectionProvider;
section: string;
updatedAt: number;
}
>;
}
const XTREAM_SECTIONS = [
'live',
'vod',
'series',
'favorites',
'recent',
'search',
'recently-added',
'downloads',
] as const;
const STALKER_SECTIONS = [
'itv',
'vod',
'series',
'favorites',
'recent',
'search',
'downloads',
] as const;
@Component({
selector: 'app-playlist-switcher',
templateUrl: './playlist-switcher.component.html',
styleUrls: ['./playlist-switcher.component.scss'],
imports: [
DatePipe,
FormsModule,
MatIcon,
MatIconButton,
MatInputModule,
MatMenuModule,
MatRippleModule,
TranslatePipe,
],
})
export class PlaylistSwitcherComponent {
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly portalStatusService = inject(PortalStatusService);
private focusSearchTimeoutId: ReturnType<typeof setTimeout> | null = null;
/** Current playlist title to display */
readonly currentTitle = input.required<string>();
/** Subtitle to display (e.g., "123 Channels" or "Xtream Code") */
readonly subtitle = input<string>('');
/** Emitted when a different playlist is selected */
readonly playlistSelected = output<string>();
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
readonly triggerElement =
viewChild.required<ElementRef<HTMLElement>>('triggerElement');
readonly searchInput =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
/** Signal for tracking menu open state */
readonly isMenuOpen = signal(false);
/** Search query for filtering playlists */
readonly searchQuery = signal(this.readPersistedSearchQuery());
readonly playlistTypeFilters = signal(this.readPersistedTypeFilters());
/** All playlists from store */
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
/** Filtered playlists based on search query, sorted by importDate (newest first) */
readonly filteredPlaylists = computed(() => {
const query = this.searchQuery().toLowerCase().trim();
const filters = this.playlistTypeFilters();
const allPlaylists = this.playlists();
const filteredByType = allPlaylists.filter((playlist) => {
const playlistType = this.getPlaylistFilterType(playlist);
return filters[playlistType];
});
const filtered = query
? filteredByType.filter(
(p) =>
p.title?.toLowerCase().includes(query) ||
p.filename?.toLowerCase().includes(query)
)
: filteredByType;
return [...filtered].sort(
(a, b) =>
new Date(b.importDate ?? 0).getTime() -
new Date(a.importDate ?? 0).getTime()
);
});
/** Current active playlist ID from route */
readonly routePlaylistId = signal<string | null>(null);
readonly selectedPlaylistId = this.store.selectSignal(
selectActivePlaylistId
);
readonly activePlaylistId = computed(() => {
return this.routePlaylistId() ?? this.selectedPlaylistId() ?? null;
});
readonly activePlaylist = computed(() => {
const playlistId = this.activePlaylistId();
if (!playlistId) {
return null;
}
return (
this.playlists().find((playlist) => playlist._id === playlistId) ??
null
);
});
readonly displayTitle = computed(() => {
if (!this.activePlaylistId()) {
return 'Select playlist';
}
return (
this.currentTitle() ||
this.activePlaylist()?.title ||
this.activePlaylist()?.filename ||
this.activePlaylist()?.url ||
this.activePlaylist()?.portalUrl ||
'Untitled playlist'
);
});
/** Portal statuses for Xtream playlists */
readonly portalStatuses = signal<Map<string, PortalStatus>>(new Map());
constructor() {
// Extract playlist ID from current route
this.updateActivePlaylistFromRoute(this.router.url);
this.rememberSectionFromRoute(this.router.url);
effect(() => {
if (!this.allPlaylistsLoaded()) {
return;
}
const playlistIds = this.playlists().map(
(playlist) => playlist._id
);
this.removeDeletedPlaylistsFromSectionMemory(playlistIds);
});
effect(() => {
const allPlaylistsLoaded = this.allPlaylistsLoaded();
const playlistIds = this.playlists().map(
(playlist) => playlist._id
);
const playlistIdSet = new Set(playlistIds);
const selectedId = this.selectedPlaylistId();
const routeId = this.routePlaylistId();
const storedId = this.readPersistedActivePlaylistId();
// Before playlists are loaded, avoid stale cleanups.
// But still keep storage in sync with explicit runtime context.
if (!allPlaylistsLoaded) {
if (routeId) {
this.writePersistedActivePlaylistId(routeId);
return;
}
if (selectedId) {
this.writePersistedActivePlaylistId(selectedId);
}
return;
}
// If route provides playlist context, keep storage in sync with URL state.
if (routeId && playlistIdSet.has(routeId)) {
this.writePersistedActivePlaylistId(routeId);
return;
}
// Keep storage up-to-date with selected playlist state.
if (selectedId && playlistIdSet.has(selectedId)) {
this.writePersistedActivePlaylistId(selectedId);
return;
}
// On routes without playlist in URL (for example dashboard),
// restore last known active playlist when store has none.
if (
!routeId &&
!selectedId &&
storedId &&
playlistIdSet.has(storedId)
) {
this.store.dispatch(
PlaylistActions.setActivePlaylist({ playlistId: storedId })
);
return;
}
// Remove stale value if playlist no longer exists.
if (storedId && !playlistIdSet.has(storedId)) {
this.clearPersistedActivePlaylistId();
}
});
// Listen for route changes
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
takeUntilDestroyed()
)
.subscribe((event: NavigationEnd) => {
this.updateActivePlaylistFromRoute(event.urlAfterRedirects);
this.rememberSectionFromRoute(event.urlAfterRedirects);
});
this.destroyRef.onDestroy(() => {
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
});
}
private updateActivePlaylistFromRoute(url: string) {
// Match routes like /playlists/:id, /xtreams/:id, /stalker/:id
const match = url.match(/\/(playlists|xtreams|stalker)\/([^/?]+)/);
if (match) {
this.routePlaylistId.set(match[2]);
} else {
this.routePlaylistId.set(null);
}
}
private rememberSectionFromRoute(url: string): void {
const routeContext = this.getRouteContext(url);
if (
!routeContext.provider ||
routeContext.provider === 'playlists' ||
!routeContext.section
) {
return;
}
const normalized = this.normalizeSectionForProvider(
routeContext.section,
routeContext.provider
);
if (!normalized) {
return;
}
const memory = this.readSectionMemory();
memory.providers[routeContext.provider] = normalized;
if (routeContext.playlistId) {
memory.playlists[routeContext.playlistId] = {
provider: routeContext.provider,
section: normalized,
updatedAt: Date.now(),
};
}
this.writeSectionMemory(memory);
}
private async checkPortalStatuses(playlists: PlaylistMeta[]) {
const statusPromises = playlists
.filter(
(playlist) =>
playlist.serverUrl && playlist.username && playlist.password
)
.map(async (playlist) => {
try {
const status =
await this.portalStatusService.checkPortalStatus(
playlist.serverUrl,
playlist.username,
playlist.password
);
return { id: playlist._id, status };
} catch {
return {
id: playlist._id,
status: 'unavailable' as PortalStatus,
};
}
});
const results = await Promise.all(statusPromises);
const statusMap = new Map(results.map((r) => [r.id, r.status]));
this.portalStatuses.set(statusMap);
}
onMenuOpened() {
this.isMenuOpen.set(true);
this.syncMenuOverlayWidthToTrigger();
this.focusSearchField();
this.checkPortalStatuses(this.playlists());
}
onMenuClosed() {
this.isMenuOpen.set(false);
this.clearMenuOverlayWidth();
this.clearSearchFocusTimeout();
}
setSearchQuery(value: string) {
this.searchQuery.set(value);
this.writePersistedSearchQuery(value);
}
togglePlaylistTypeFilter(type: PlaylistFilterType, event?: Event): void {
event?.stopPropagation();
const current = this.playlistTypeFilters();
const next = {
...current,
[type]: !current[type],
};
if (!next.m3u && !next.stalker && !next.xtream) {
return;
}
this.playlistTypeFilters.set(next);
this.writePersistedTypeFilters(next);
}
isTypeFilterSelected(type: PlaylistFilterType): boolean {
return this.playlistTypeFilters()[type];
}
private syncMenuOverlayWidthToTrigger() {
const triggerWidth = Math.round(
this.triggerElement().nativeElement.getBoundingClientRect().width
);
this.document.documentElement.style.setProperty(
'--playlist-switcher-overlay-width',
`${triggerWidth}px`
);
}
private clearMenuOverlayWidth() {
this.document.documentElement.style.removeProperty(
'--playlist-switcher-overlay-width'
);
}
private focusSearchField() {
this.clearSearchFocusTimeout();
this.focusSearchTimeoutId = setTimeout(() => {
const input = this.searchInput()?.nativeElement;
if (!input) {
return;
}
input.focus();
input.select();
this.focusSearchTimeoutId = null;
}, 0);
}
private clearSearchFocusTimeout() {
if (this.focusSearchTimeoutId !== null) {
clearTimeout(this.focusSearchTimeoutId);
this.focusSearchTimeoutId = null;
}
}
selectPlaylist(playlist: PlaylistMeta) {
const routeContext = this.getRouteContext(this.router.url);
const provider = this.getProviderForPlaylist(playlist);
this.setActivePlaylist(playlist._id);
const targetCommands = this.getTargetCommands(
provider,
playlist._id,
routeContext
);
if (targetCommands) {
this.router.navigate(targetCommands);
}
this.playlistSelected.emit(playlist._id);
}
private setActivePlaylist(playlistId: string): void {
this.store.dispatch(PlaylistActions.setActivePlaylist({ playlistId }));
}
private isWorkspaceRoute(url: string): boolean {
return /^\/workspace(?:\/|$)/.test(url);
}
private getProviderForPlaylist(
playlist: PlaylistMeta
): PlaylistRouteProvider {
if (playlist.serverUrl) {
return 'xtreams';
}
if (playlist.macAddress) {
return 'stalker';
}
return 'playlists';
}
private getPlaylistFilterType(playlist: PlaylistMeta): PlaylistFilterType {
if (playlist.macAddress) {
return 'stalker';
}
if (playlist.serverUrl) {
return 'xtream';
}
return 'm3u';
}
private getRouteContext(url: string): PlaylistRouteContext {
const inWorkspace = this.isWorkspaceRoute(url);
const isWorkspaceDashboard =
/^\/workspace(?:\/dashboard)?(?:\/)?(?:\?.*)?$/.test(url);
const match = url.match(
/^\/(?:workspace\/)?(playlists|xtreams|stalker)\/([^/?]+)(?:\/([^/?]+))?/
);
return {
inWorkspace,
isWorkspaceDashboard,
provider: (match?.[1] as PlaylistRouteProvider | undefined) ?? null,
playlistId: match?.[2] ?? null,
section: match?.[3] ?? null,
};
}
private getTargetCommands(
provider: PlaylistRouteProvider,
playlistId: string,
routeContext: PlaylistRouteContext
): string[] | null {
const prefix = routeContext.inWorkspace ? ['workspace'] : [];
// In workspace routes without explicit provider context (for example
// dashboard and sources), selecting a source should only change active
// context and must not trigger navigation.
if (routeContext.inWorkspace && !routeContext.provider) {
return null;
}
if (provider === 'playlists') {
return [...prefix, 'playlists', playlistId];
}
if (!this.supportsSectionNavigation(provider)) {
return [...prefix, provider, playlistId];
}
const section = this.resolveTargetSection(
provider,
routeContext.section,
playlistId
);
return [...prefix, provider, playlistId, section];
}
private resolveTargetSection(
provider: PlaylistSectionProvider,
currentSection: string | null,
targetPlaylistId: string
): string {
const fromCurrent = this.normalizeSectionForProvider(
currentSection,
provider
);
if (fromCurrent) {
return fromCurrent;
}
const memory = this.readSectionMemory();
const playlistMemory = memory.playlists[targetPlaylistId];
if (playlistMemory?.provider === provider) {
const normalizedPlaylistSection = this.normalizeSectionForProvider(
playlistMemory.section,
provider
);
if (normalizedPlaylistSection) {
return normalizedPlaylistSection;
}
}
const providerMemory = memory.providers[provider];
const normalizedProviderSection = this.normalizeSectionForProvider(
providerMemory,
provider
);
if (normalizedProviderSection) {
return normalizedProviderSection;
}
return 'vod';
}
private supportsSectionNavigation(
provider: PlaylistSectionProvider
): boolean {
if (provider === 'xtreams') {
return Boolean(window.electron);
}
return true;
}
private normalizeSectionForProvider(
section: string | null | undefined,
provider: PlaylistSectionProvider
): string | null {
if (!section) {
return null;
}
if (provider === 'xtreams') {
if (section === 'itv') {
return 'live';
}
return XTREAM_SECTIONS.includes(
section as (typeof XTREAM_SECTIONS)[number]
)
? section
: null;
}
if (section === 'live') {
return 'itv';
}
if (section === 'recently-added') {
return 'recent';
}
return STALKER_SECTIONS.includes(
section as (typeof STALKER_SECTIONS)[number]
)
? section
: null;
}
private readSectionMemory(): PlaylistSectionMemory {
const empty: PlaylistSectionMemory = { providers: {}, playlists: {} };
try {
const raw = localStorage.getItem(LAST_SECTION_STORAGE_KEY);
if (!raw) {
return empty;
}
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) {
return empty;
}
const providers: Partial<Record<PlaylistSectionProvider, string>> =
{};
const playlists: PlaylistSectionMemory['playlists'] = {};
const parsedProviders = (parsed as Record<string, unknown>)[
'providers'
];
if (
typeof parsedProviders === 'object' &&
parsedProviders !== null
) {
const candidate = parsedProviders as Record<string, unknown>;
if (typeof candidate['xtreams'] === 'string') {
providers.xtreams = candidate['xtreams'];
}
if (typeof candidate['stalker'] === 'string') {
providers.stalker = candidate['stalker'];
}
} else {
// Backward compatibility with previous format:
// { xtreams: "...", stalker: "..." }
const legacy = parsed as Record<string, unknown>;
if (typeof legacy['xtreams'] === 'string') {
providers.xtreams = legacy['xtreams'];
}
if (typeof legacy['stalker'] === 'string') {
providers.stalker = legacy['stalker'];
}
}
const parsedPlaylists = (parsed as Record<string, unknown>)[
'playlists'
];
if (
typeof parsedPlaylists === 'object' &&
parsedPlaylists !== null
) {
Object.entries(
parsedPlaylists as Record<string, unknown>
).forEach(([playlistId, value]) => {
if (!value || typeof value !== 'object' || !playlistId) {
return;
}
const row = value as Record<string, unknown>;
const provider = row['provider'];
const section = row['section'];
const updatedAt = row['updatedAt'];
const isProviderValid =
provider === 'xtreams' || provider === 'stalker';
if (!isProviderValid || typeof section !== 'string') {
return;
}
playlists[playlistId] = {
provider,
section,
updatedAt:
typeof updatedAt === 'number'
? updatedAt
: Date.now(),
};
});
}
return { providers, playlists };
} catch {
return empty;
}
}
private writeSectionMemory(memory: PlaylistSectionMemory): void {
try {
localStorage.setItem(
LAST_SECTION_STORAGE_KEY,
JSON.stringify(memory)
);
} catch {
// Ignore storage write failures (private mode/storage quota).
}
}
private readPersistedActivePlaylistId(): string | null {
try {
const value = localStorage.getItem(
LAST_ACTIVE_PLAYLIST_STORAGE_KEY
);
return value && value.trim().length > 0 ? value : null;
} catch {
return null;
}
}
private writePersistedActivePlaylistId(playlistId: string): void {
try {
localStorage.setItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY, playlistId);
} catch {
// Ignore storage write failures (private mode/storage quota).
}
}
private clearPersistedActivePlaylistId(): void {
try {
localStorage.removeItem(LAST_ACTIVE_PLAYLIST_STORAGE_KEY);
} catch {
// Ignore storage write failures.
}
}
private readPersistedSearchQuery(): string {
try {
return localStorage.getItem(SEARCH_QUERY_STORAGE_KEY) ?? '';
} catch {
return '';
}
}
private writePersistedSearchQuery(value: string): void {
try {
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, value);
} catch {
// Ignore storage write failures (private mode/storage quota).
}
}
private readPersistedTypeFilters(): Record<PlaylistFilterType, boolean> {
try {
const raw = localStorage.getItem(PLAYLIST_TYPE_FILTER_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
const parsed = JSON.parse(raw) as Partial<
Record<PlaylistFilterType, boolean>
>;
const next = {
m3u: parsed.m3u !== false,
stalker: parsed.stalker !== false,
xtream: parsed.xtream !== false,
};
if (!next.m3u && !next.stalker && !next.xtream) {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
return next;
} catch {
return { ...DEFAULT_PLAYLIST_TYPE_FILTERS };
}
}
private writePersistedTypeFilters(
filters: Record<PlaylistFilterType, boolean>
): void {
try {
localStorage.setItem(
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
JSON.stringify(filters)
);
} catch {
// Ignore storage write failures (private mode/storage quota).
}
}
private removeDeletedPlaylistsFromSectionMemory(
existingIds: string[]
): void {
const existingIdSet = new Set(existingIds);
const memory = this.readSectionMemory();
const currentEntries = Object.entries(memory.playlists);
if (currentEntries.length === 0) {
return;
}
const nextPlaylists: PlaylistSectionMemory['playlists'] = {};
let hasChanges = false;
currentEntries.forEach(([playlistId, entry]) => {
if (!existingIdSet.has(playlistId)) {
hasChanges = true;
return;
}
const normalizedSection = this.normalizeSectionForProvider(
entry.section,
entry.provider
);
if (!normalizedSection) {
hasChanges = true;
return;
}
nextPlaylists[playlistId] = {
...entry,
section: normalizedSection,
};
});
if (!hasChanges) {
return;
}
this.writeSectionMemory({
providers: memory.providers,
playlists: nextPlaylists,
});
}
getPlaylistIcon(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return 'dashboard';
}
if (playlist.serverUrl) {
return 'public';
}
if (playlist.url) {
return 'cloud';
}
return 'folder';
}
getPlaylistTypeLabel(playlist: PlaylistMeta): string {
if (playlist.macAddress) {
return 'Stalker Portal';
}
if (playlist.serverUrl) {
return 'Xtream Code';
}
return `${playlist.count} channels`;
}
getStatusClass(playlistId: string): string {
const status = this.portalStatuses().get(playlistId);
return this.portalStatusService.getStatusClass(status || 'unavailable');
}
isSelected(playlist: PlaylistMeta): boolean {
return this.activePlaylistId() === playlist._id;
}
}
@@ -1,40 +0,0 @@
@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>
}
@@ -1,106 +0,0 @@
: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;
}
}
@@ -1,63 +0,0 @@
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();
}
}
@@ -1,87 +0,0 @@
@if (type() === 'welcome') {
<div class="empty-state welcome-state">
<div class="illustration-container">
<!-- Monitor with WiFi signal -->
<svg class="illustration" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<!-- Background rounded rectangle -->
<rect x="40" y="40" width="432" height="432" rx="80" class="icon-background"/>
<!-- Monitor base -->
<rect x="134" y="387" width="244" height="18" rx="4" class="monitor-base"/>
<!-- Monitor stand -->
<rect x="238" y="347" width="36" height="40" rx="4" class="monitor-stand"/>
<!-- Monitor screen border -->
<rect x="88" y="102" width="336" height="245" rx="12" stroke="currentColor" stroke-width="12" fill="none" class="screen-border"/>
<!-- WiFi symbol -->
<!-- Bottom dot -->
<circle cx="256" cy="280" r="16" class="wifi-dot"/>
<!-- First arc (smallest) -->
<path d="M 220 250 Q 256 220, 292 250" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-1"/>
<!-- Second arc (medium) -->
<path d="M 200 230 Q 256 185, 312 230" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-2"/>
<!-- Third arc (largest) -->
<path d="M 180 210 Q 256 150, 332 210" stroke="currentColor" stroke-width="14" fill="none" stroke-linecap="round" class="wifi-arc arc-3"/>
</svg>
</div>
<div class="content">
<h2 class="title">{{ 'HOME.PLAYLISTS.WELCOME_TITLE' | translate }}</h2>
<p class="description">{{ 'HOME.PLAYLISTS.WELCOME_DESCRIPTION' | translate }}</p>
<div class="actions">
<button
mat-raised-button
class="primary-button"
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
>
<mat-icon>add_circle</mat-icon>
{{ 'HOME.PLAYLISTS.ADD_YOUR_FIRST' | translate }}
</button>
<app-add-playlist-menu
(playlistTypeSelected)="onAddPlaylist($event)"
/>
</div>
<div class="features">
<div class="feature-item">
<mat-icon class="feature-icon">folder_open</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_M3U' | translate }}</span>
</div>
<div class="feature-item">
<mat-icon class="feature-icon">cloud</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_XTREAM' | translate }}</span>
</div>
<div class="feature-item">
<mat-icon class="feature-icon">cast</mat-icon>
<span>{{ 'HOME.PLAYLISTS.FEATURE_STALKER' | translate }}</span>
</div>
</div>
</div>
</div>
} @else {
<div class="empty-state no-results-state">
<div class="illustration-container small">
<!-- Search/Empty themed illustration -->
<svg class="illustration" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Magnifying glass -->
<circle cx="65" cy="65" r="35" class="search-circle" stroke-width="4"/>
<line x1="90" y1="90" x2="115" y2="115" class="search-handle" stroke-width="4" stroke-linecap="round"/>
<!-- Empty box -->
<rect x="45" y="50" width="40" height="30" rx="3" class="empty-box" stroke-width="2" opacity="0.4"/>
<line x1="55" y1="65" x2="75" y2="65" class="empty-line" stroke-width="2" opacity="0.3"/>
</svg>
</div>
<div class="content">
<h3 class="title small">{{ 'HOME.PLAYLISTS.NO_RESULTS_TITLE' | translate }}</h3>
<p class="description">{{ 'HOME.PLAYLISTS.NO_RESULTS_DESCRIPTION' | translate }}</p>
</div>
</div>
}
@@ -1,486 +0,0 @@
:host {
display: block;
width: 100%;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
text-align: center;
animation: fadeIn 0.6s ease-out;
.content {
max-width: 480px;
animation: slideUp 0.8s ease-out;
}
.title {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
margin: 0 0 12px 0;
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
&.small {
font-size: 1.5rem;
}
}
.description {
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.95rem;
line-height: 1.6;
margin: 0 0 32px 0;
opacity: 0.7;
}
}
.welcome-state {
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(59, 130, 246, 0.03) 39px,
rgba(59, 130, 246, 0.03) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(59, 130, 246, 0.015) 79px,
rgba(59, 130, 246, 0.015) 81px
);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
animation: gridMove 60s linear infinite;
pointer-events: none;
z-index: 0;
}
> * {
position: relative;
z-index: 1;
}
.illustration-container {
margin-bottom: 32px;
position: relative;
}
.illustration {
width: 200px;
height: 200px;
filter: drop-shadow(0 10px 30px rgba(59, 130, 246, 0.2));
color: #60a5fa;
.icon-background {
fill: #3b82f6;
opacity: 0.15;
}
.monitor-base,
.monitor-stand {
fill: #3b82f6;
}
.screen-border {
stroke: #3b82f6;
}
.wifi-dot {
fill: #60a5fa;
}
.wifi-arc {
stroke: #60a5fa;
opacity: 0.7;
&.arc-1 {
opacity: 0.9;
}
&.arc-2 {
opacity: 0.7;
}
&.arc-3 {
opacity: 0.5;
}
}
}
.title {
font-size: 2rem;
letter-spacing: -0.02em;
}
.actions {
margin-bottom: 40px;
.primary-button {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-weight: 500;
font-size: 1rem;
padding: 12px 32px;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.3);
transition: all 0.3s ease;
text-transform: none;
letter-spacing: 0.01em;
mat-icon {
margin-right: 8px;
}
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4);
}
&:active {
transform: translateY(0);
}
}
}
.features {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
opacity: 0.6;
.feature-item {
display: flex;
align-items: center;
gap: 8px;
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 0.875rem;
padding: 8px 16px;
background: rgba(59, 130, 246, 0.08);
border-radius: 8px;
border: 1px solid rgba(59, 130, 246, 0.15);
transition: all 0.3s ease;
.feature-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: #60a5fa;
}
&:hover {
background: rgba(59, 130, 246, 0.12);
border-color: rgba(59, 130, 246, 0.25);
opacity: 1;
}
}
}
}
.no-results-state {
padding: 40px 24px;
.illustration-container {
margin-bottom: 24px;
opacity: 0.5;
&.small {
animation: gentleFloat 1s ease-out;
}
}
.illustration {
width: 120px;
height: 120px;
.search-circle {
stroke: currentColor;
opacity: 0.4;
}
.search-handle {
stroke: currentColor;
opacity: 0.4;
}
.empty-box {
stroke: currentColor;
}
.empty-line {
stroke: currentColor;
}
}
.description {
margin-bottom: 0;
font-size: 0.875rem;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes floatIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes gentleFloat {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 0.5;
transform: scale(1);
}
}
@keyframes gridMove {
0% {
transform: translate(0, 0) rotate(0deg);
}
100% {
transform: translate(40px, 40px) rotate(0.5deg);
}
}
@media (max-width: 480px) {
.welcome-state {
padding: 40px 20px;
.illustration {
width: 160px;
height: 160px;
}
.title {
font-size: 1.5rem;
}
.features {
flex-direction: column;
gap: 12px;
align-items: stretch;
}
}
.no-results-state {
padding: 30px 20px;
.illustration {
width: 100px;
height: 100px;
}
}
}
@media (max-height: 700px) {
.welcome-state {
padding: 30px 24px;
.illustration-container { margin-bottom: 20px; }
.illustration { width: 150px; height: 150px; }
.title { font-size: 1.5rem; margin-bottom: 8px; }
.description { font-size: 0.875rem; margin-bottom: 20px; }
.actions { margin-bottom: 24px; }
.features { gap: 16px; }
.feature-item { padding: 6px 12px; font-size: 0.8125rem; }
}
.no-results-state {
padding: 24px 20px;
.illustration-container { margin-bottom: 16px; }
}
}
@media (max-height: 550px) {
.welcome-state {
padding: 20px 24px;
.illustration-container { margin-bottom: 16px; }
.illustration { width: 120px; height: 120px; }
.title { font-size: 1.25rem; margin-bottom: 6px; }
.description { font-size: 0.8125rem; margin-bottom: 16px; }
.actions { margin-bottom: 16px; }
.primary-button { padding: 10px 24px; font-size: 0.9rem; }
.features { gap: 12px; }
.feature-item { padding: 4px 10px; font-size: 0.75rem; }
.feature-icon { font-size: 16px; width: 16px; height: 16px; }
}
.no-results-state {
padding: 16px 20px;
.illustration-container { margin-bottom: 12px; }
.illustration { width: 80px; height: 80px; }
.title { font-size: 1.125rem; }
.description { font-size: 0.8125rem; }
}
}
@media (max-width: 480px) and (max-height: 700px) {
.welcome-state {
padding: 20px 16px;
.illustration {
width: 120px;
height: 120px;
}
.title {
font-size: 1.25rem;
}
}
}
@media (prefers-color-scheme: light) {
.empty-state {
.title {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.description {
opacity: 0.8;
color: #64748b;
}
}
.welcome-state {
// Light theme grid
&::before {
background-image:
repeating-linear-gradient(
0deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 39px,
rgba(37, 99, 235, 0.04) 39px,
rgba(37, 99, 235, 0.04) 41px
),
repeating-linear-gradient(
45deg,
transparent,
transparent 79px,
rgba(37, 99, 235, 0.02) 79px,
rgba(37, 99, 235, 0.02) 81px
);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.7) 40%, rgba(0, 0, 0, 0.3) 70%, transparent 100%);
}
.illustration {
filter: drop-shadow(0 10px 30px rgba(37, 99, 235, 0.15));
color: #3b82f6;
.icon-background {
fill: #2563eb;
opacity: 0.12;
}
.monitor-base,
.monitor-stand {
fill: #2563eb;
}
.screen-border {
stroke: #2563eb;
}
.wifi-dot {
fill: #3b82f6;
}
.wifi-arc {
stroke: #3b82f6;
}
}
.features {
.feature-item {
background: rgba(37, 99, 235, 0.06);
border-color: rgba(37, 99, 235, 0.12);
.feature-icon {
color: #2563eb;
}
&:hover {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.2);
}
}
}
}
.no-results-state {
.illustration {
opacity: 0.3;
.search-circle,
.search-handle,
.empty-box,
.empty-line {
stroke: #64748b;
}
}
}
}
@@ -1,24 +0,0 @@
import { Component, input, output, viewChild } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { AddPlaylistMenuComponent, PlaylistType } from '../../add-playlist-menu/add-playlist-menu.component';
export type EmptyStateType = 'welcome' | 'no-results';
@Component({
selector: 'app-empty-state',
templateUrl: './empty-state.component.html',
styleUrls: ['./empty-state.component.scss'],
imports: [AddPlaylistMenuComponent, MatButtonModule, MatIcon, MatMenuModule, TranslatePipe],
})
export class EmptyStateComponent {
readonly addPlaylistMenuComponent = viewChild.required(AddPlaylistMenuComponent);
readonly type = input.required<EmptyStateType>();
readonly addPlaylistClicked = output<PlaylistType>();
onAddPlaylist(playlistType: PlaylistType): void {
this.addPlaylistClicked.emit(playlistType);
}
}
@@ -1,193 +0,0 @@
<form
[formGroup]="playlistDetails"
(ngSubmit)="saveChanges(playlistDetails.value)"
>
<h2 mat-dialog-title>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_DETAILS' | translate }}
</h2>
<mat-dialog-content class="mat-typography">
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.TITLE' | translate
}}</mat-label>
<input formControlName="title" matInput />
</mat-form-field>
@if (playlist.portalUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="portalUrl" />
</mat-form-field>
}
@if (playlist.macAddress) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.MAC_ADDRESS' | translate
}}</mat-label>
<input matInput formControlName="macAddress" />
</mat-form-field>
}
@if (playlist.portalUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SERIAL_NUMBER' | translate
}}</mat-label>
<input matInput formControlName="stalkerSerialNumber" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.DEVICE_ID_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerDeviceId2" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_1' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature1" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.STALKER_PORTAL.SIGNATURE_2' | translate
}}</mat-label>
<input matInput formControlName="stalkerSignature2" />
</mat-form-field>
}
@if (playlist.serverUrl) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.SERVER_URL' | translate
}}</mat-label>
<input matInput formControlName="serverUrl" />
</mat-form-field>
}
@if (playlist.username) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USERNAME' | translate
}}</mat-label>
<input matInput formControlName="username" />
</mat-form-field>
}
@if (playlist.password) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.PASSWORD' | translate
}}</mat-label>
<input matInput formControlName="password" />
</mat-form-field>
}
@if (playlist.url) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.FROM_URL' | translate
}}</mat-label>
<input matInput formControlName="url" />
<button
mat-icon-button
matSuffix
type="button"
(click)="copyUrl()"
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.COPY_URL' | translate
"
>
<mat-icon>content_copy</mat-icon>
</button>
</mat-form-field>
}
@if (playlist.filename) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.ORIGINAL_FILENAME' | translate
}}</mat-label>
<input matInput formControlName="filename" />
</mat-form-field>
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.IMPORT_DATE' | translate
}}</mat-label>
<input matInput formControlName="importDate" />
</mat-form-field>
}
@if (playlist.count) {
<mat-form-field class="w-full">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.CHANNELS' | translate
}}</mat-label>
<input matInput formControlName="count" />
</mat-form-field>
}
@if (isDesktop) {
<mat-form-field class="w-full mb-10">
<mat-label>{{
'HOME.PLAYLISTS.INFO_DIALOG.USER_AGENT' | translate
}}</mat-label>
<input formControlName="userAgent" matInput />
<mat-hint>{{
'HOME.PLAYLISTS.INFO_DIALOG.CUSTOM_USER_AGENT' | translate
}}</mat-hint>
</mat-form-field>
}
@if (playlist.filePath) {
<mat-form-field class="w-full">
<mat-label>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.FILE_PATH' | translate }}
</mat-label>
<input formControlName="filePath" matInput />
@if (playlist.updateState === 2) {
<mat-hint>
{{
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED'
| translate
}}
</mat-hint>
}
</mat-form-field>
}
@if (isDesktop && (playlist.url || playlist.filePath)) {
<mat-checkbox class="w-full" formControlName="autoRefresh">
{{ 'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE' | translate }}
</mat-checkbox>
<p>
{{
'HOME.PLAYLISTS.INFO_DIALOG.AUTO_UPDATE_DESCRIPTION'
| translate
}}
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
@if (!playlist.serverUrl && !playlist.portalUrl) {
<button
mat-flat-button
color="accent"
type="button"
(click)="exportPlaylist()"
>
<mat-icon>download</mat-icon>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST' | translate }}
</button>
}
<span class="spacer"></span>
<button mat-button mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
<button
mat-flat-button
mat-dialog-close
color="accent"
type="submit"
[disabled]="!playlistDetails.valid || playlistDetails.pristine"
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.SAVE' | translate }}
</button>
</mat-dialog-actions>
</form>
@@ -1,250 +0,0 @@
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { DatePipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import {
FormControl,
ReactiveFormsModule,
UntypedFormBuilder,
UntypedFormGroup,
Validators,
} from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { firstValueFrom } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Playlist, PlaylistMeta } from 'shared-interfaces';
@Component({
selector: 'app-playlist-info',
templateUrl: './playlist-info.component.html',
styles: [
`
.spacer {
flex: 1 1 auto;
}
`,
],
providers: [DatePipe],
imports: [
ClipboardModule,
MatButton,
MatCheckboxModule,
MatDialogModule,
MatIcon,
MatIconButton,
MatInputModule,
MatTooltip,
ReactiveFormsModule,
TranslatePipe,
],
})
export class PlaylistInfoComponent {
private clipboard = inject(Clipboard);
private datePipe = inject(DatePipe);
private formBuilder = inject(UntypedFormBuilder);
private playlistsService = inject(PlaylistsService);
private store = inject(Store);
private databaseService = inject(DatabaseService);
private snackBar = inject(MatSnackBar);
private translate = inject(TranslateService);
public playlistData = inject<Playlist & { id: string }>(MAT_DIALOG_DATA);
readonly isDesktop = !!window.electron;
/** Playlist object */
playlist: Playlist & { id: string };
/** Form group with playlist details */
playlistDetails!: UntypedFormGroup;
constructor() {
this.playlist = this.playlistData;
this.createForm();
}
/**
* Create the form and set initial data on component init
*/
createForm(): void {
this.playlistDetails = this.formBuilder.group({
_id: this.playlist._id,
title: new FormControl(this.playlist.title, Validators.required),
userAgent: this.playlist.userAgent || '',
filename: new FormControl({
value: this.playlist.filename || '',
disabled: true,
}),
count: new FormControl({
value: this.playlist.count,
disabled: true,
}),
importDate: new FormControl({
value: this.datePipe.transform(this.playlist.importDate),
disabled: true,
}),
url: new FormControl({
value: this.playlist.url,
disabled: true,
}),
filePath: new FormControl({
value: this.playlist.filePath,
disabled: true,
}),
autoRefresh: new FormControl(this.playlist.autoRefresh),
serverUrl: new FormControl(this.playlist.serverUrl),
username: new FormControl(this.playlist.username),
password: new FormControl(this.playlist.password),
macAddress: new FormControl(this.playlist.macAddress),
portalUrl: new FormControl(this.playlist.portalUrl),
stalkerSerialNumber: new FormControl(
this.playlist.stalkerSerialNumber
),
stalkerDeviceId1: new FormControl(this.playlist.stalkerDeviceId1),
stalkerDeviceId2: new FormControl(this.playlist.stalkerDeviceId2),
stalkerSignature1: new FormControl(this.playlist.stalkerSignature1),
stalkerSignature2: new FormControl(this.playlist.stalkerSignature2),
});
}
async saveChanges(playlist: PlaylistMeta): Promise<void> {
try {
const isXtream =
this.playlist &&
this.playlist.username &&
this.playlist.password &&
this.playlist.serverUrl;
if (isXtream) {
await this.updateXtreamPlaylist(playlist);
}
// Dispatch store action to update UI
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({ playlist })
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
} catch (error) {
console.error('Error updating playlist:', error);
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_FAILED'),
this.translate.instant('CLOSE'),
{
duration: 3000,
}
);
}
}
async updateXtreamPlaylist(playlist: PlaylistMeta) {
const success = await this.databaseService.updateXtreamPlaylistDetails({
id: this.playlist._id,
title: playlist.title,
username: playlist.username,
password: playlist.password,
serverUrl: playlist.serverUrl,
});
if (!success) {
throw new Error('Failed to update playlist in database');
}
// TODO: circular dependency
/* this.xtreamStore.updatePlaylist({
name: playlist.title,
username: playlist.username,
password: playlist.password,
serverUrl: playlist.serverUrl,
}); */
}
async exportPlaylist() {
const playlistAsString = await firstValueFrom(
this.playlistsService.getRawPlaylistById(this.playlist._id)
);
if (this.isDesktop) {
try {
const savePath = await window.electron.saveFileDialog(
`${this.playlist.title || 'exported'}.m3u8`,
[
{
name: 'Playlist',
extensions: ['m3u8', 'm3u'],
},
]
);
if (savePath) {
await window.electron.writeFile(savePath, playlistAsString);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.INFO_DIALOG.PLAYLIST_EXPORT_SUCCESS'
),
this.translate.instant('CLOSE'),
{ duration: 3000 }
);
}
} catch (error) {
console.error('Failed to export playlist:', error);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.INFO_DIALOG.EXPORT_PLAYLIST_FAILED'
),
this.translate.instant('CLOSE'),
{
duration: 3000,
}
);
}
} else {
const element = document.createElement('a');
element.setAttribute(
'href',
'data:text/plain;charset=utf-8,' +
encodeURIComponent(playlistAsString)
);
element.setAttribute(
'download',
this.playlist.title || 'exported.m3u'
);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
}
/**
* Copy URL to clipboard
*/
copyUrl(): void {
const url = this.playlistDetails.get('url')?.value;
if (url) {
const success = this.clipboard.copy(url);
if (success) {
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.INFO_DIALOG.URL_COPIED'
),
this.translate.instant('CLOSE'),
{ duration: 2000 }
);
}
}
}
}
@@ -1,162 +0,0 @@
<mat-list-item
cdkDrag
[cdkDragDisabled]="!isDraggable()"
cdkDragPreviewContainer="parent"
[class.selected]="isSelected()"
(click)="playlistClicked.emit(item._id)"
>
@if (isDraggable()) {
<mat-icon cdkDragHandle class="drag-icon" matListItemIcon
>drag_indicator</mat-icon
>
}
@if (item.url) {
<div class="icon-container" matListItemIcon>
<mat-icon
class="upload-type-icon"
[matTooltip]="
('HOME.PLAYLISTS.ADDED_VIA_URL' | translate) + ' ' + item.url
"
>cloud</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate"
>autorenew</mat-icon
>
}
</div>
}
@if (!item.url && !item.serverUrl && !item.macAddress) {
<div class="icon-container" matListItemIcon>
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.ADDED_VIA_FILE' | translate"
>folder</mat-icon
>
@if (item.autoRefresh) {
<mat-icon
class="auto-refresh-indicator"
[matTooltip]="'HOME.PLAYLISTS.AUTO_REFRESH_ENABLED' | translate"
>autorenew</mat-icon
>
}
</div>
}
@if (item.serverUrl) {
<div class="icon-container" matListItemIcon>
<mat-icon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.XTREAM_PLAYLIST' | translate"
>public</mat-icon
>
<span
class="status-dot"
[class]="getStatusClass()"
[matTooltip]="'Portal status: ' + portalStatus"
[attr.aria-label]="'Portal status: ' + portalStatus"
></span>
</div>
}
@if (item.macAddress) {
<mat-icon
matListItemIcon
class="upload-type-icon"
[matTooltip]="'HOME.PLAYLISTS.STALKER_PORTAL' | translate"
>dashboard</mat-icon
>
}
<div matListItemTitle>
{{ item.title || item.filename }}
</div>
<div matListItemLine class="meta">
@if (!item.serverUrl && !item.portalUrl) {
{{ 'HOME.PLAYLISTS.CHANNELS' | translate }}: {{ item.count }}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date }}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }}
}
@if (item.updateState === 2) {
<mat-icon
[matTooltip]="
'HOME.PLAYLISTS.INFO_DIALOG.UPDATE_FAILED' | translate
"
[inline]="true"
>warning</mat-icon
>
}
} @else {
@if (item.macAddress) {
Stalker Portal
} @else {
Xtream Code Playlist
}
@if (item.importDate.length > 3) {
| {{ 'HOME.PLAYLISTS.ADDED' | translate }}:
{{ item.importDate | date }}
}
@if (item.updateDate) {
| {{ 'HOME.PLAYLISTS.UPDATED' | translate }}:
{{ item.updateDate | date: 'MMMM d, yyyy, HH:mm' }}
}
}
</div>
@if (showActions()) {
<span matListItemMeta class="action-buttons">
@if (item.url || item.filePath) {
<button
mat-icon-button
class="refresh-btn"
color="accent"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[matTooltip]="'HOME.PLAYLISTS.REFRESH' | translate"
>
<mat-icon>sync</mat-icon>
</button>
}
@if (item.serverUrl && isElectron) {
<button
mat-icon-button
class="refresh-btn"
color="accent"
(click)="
$event.stopPropagation(); refreshClicked.emit(item)
"
[matTooltip]="'HOME.PLAYLISTS.REFRESH_XTREAM' | translate"
>
<mat-icon>sync</mat-icon>
</button>
}
<button
mat-icon-button
color="accent"
class="edit-btn"
(click)="
$event.stopPropagation(); editPlaylistClicked.emit(item)
"
[matTooltip]="'HOME.PLAYLISTS.SHOW_DETAILS' | translate"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="accent"
class="delete-btn"
(click)="$event.stopPropagation(); removeClicked.emit(item._id)"
[matTooltip]="'HOME.PLAYLISTS.REMOVE' | translate"
>
<mat-icon>delete</mat-icon>
</button>
</span>
}
</mat-list-item>
@@ -1,215 +0,0 @@
mat-list-item {
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 12px;
margin: 2px 4px;
border: 1px solid transparent;
&:hover {
background-color: rgba(255, 255, 255, 0.03) !important;
}
&.selected {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
) !important;
border-color: var(--app-selection-border) !important;
color: var(--app-selection-color) !important;
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.meta,
.upload-type-icon,
.drag-icon,
.action-buttons button,
.action-buttons mat-icon {
color: inherit !important;
}
.status-dot {
border-color: color-mix(
in srgb,
var(--app-selection-surface-strong) 85%,
transparent
);
}
}
}
// Light theme hover
@media (prefers-color-scheme: light) {
mat-list-item:hover {
background-color: rgba(0, 0, 0, 0.04) !important;
}
}
.mat-list-base {
padding-top: 0 !important;
}
.meta {
font-size: 12px !important;
color: #666;
margin-top: 2px !important;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 12px;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.25),
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
opacity: 0.9;
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.drag-icon {
cursor: move;
margin-right: 0;
opacity: 0.5;
transition: opacity 0.2s ease;
&:hover {
opacity: 1;
}
}
::ng-deep {
@media only screen and (max-width: 480px) {
.main-container {
.favorites-icon,
.upload-type-icon,
.refresh-btn,
.edit-btn,
.delete-btn {
display: none;
}
}
.mdc-list-item__content {
padding-left: 20px;
}
}
}
.action-buttons {
align-self: center !important;
button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(255, 255, 255, 0.06) !important;
transform: scale(1.05);
mat-icon {
opacity: 1;
}
}
mat-icon {
opacity: 0.85;
transition: opacity 0.3s ease;
}
}
}
// Light theme action buttons
@media (prefers-color-scheme: light) {
.action-buttons button:hover {
background-color: rgba(0, 0, 0, 0.04) !important;
}
}
.icon-container {
position: relative;
display: inline-flex;
.upload-type-icon {
opacity: 0.75;
transition: opacity 0.3s ease;
}
mat-list-item:hover & .upload-type-icon {
opacity: 0.95;
}
}
.status-dot {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
right: 0;
bottom: 0;
border: 2px solid var(--background-color, #131316);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
.status-active {
background-color: #4caf50;
box-shadow: 0 0 8px rgba(76, 175, 80, 0.4);
}
.status-inactive {
background-color: #f44336;
box-shadow: 0 0 8px rgba(244, 67, 54, 0.4);
}
.status-expired {
background-color: #ff9800;
box-shadow: 0 0 8px rgba(255, 152, 0, 0.4);
}
.status-unavailable {
background-color: #666666;
}
.auto-refresh-indicator {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 14px !important;
width: 14px !important;
height: 14px !important;
color: #4caf50;
background-color: var(--background-color, #131316);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
mat-list-item:hover & {
color: #66bb6a;
transform: rotate(90deg);
}
}
// Light theme auto-refresh indicator
@media (prefers-color-scheme: light) {
.auto-refresh-indicator {
background-color: #ffffff;
}
}
@media only screen and (max-width: 480px) {
.upload-type-icon,
.icon-container {
display: none;
}
}
@@ -1,43 +0,0 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { DataService } from 'services';
import { PlaylistItemComponent } from './playlist-item.component';
describe('PlaylistItemComponent', () => {
let component: PlaylistItemComponent;
let fixture: ComponentFixture<PlaylistItemComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
PlaylistItemComponent,
MockModule(MatIconModule),
MockModule(MatListModule),
MockModule(MatTooltipModule),
TranslateModule.forRoot(),
],
providers: [DataService],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(PlaylistItemComponent);
component = fixture.componentInstance;
component.item = {
title: 'Playlist',
_id: '1',
count: 10,
importDate: Date.now().toString(),
autoRefresh: false,
};
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -1,73 +0,0 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common';
import { Component, Input, OnInit, inject, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PortalStatus, PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
@Component({
selector: 'app-playlist-item',
templateUrl: './playlist-item.component.html',
styleUrls: ['./playlist-item.component.scss'],
imports: [
DatePipe,
DragDropModule,
MatIconButton,
MatIcon,
MatListModule,
MatTooltip,
TranslatePipe,
],
})
export class PlaylistItemComponent implements OnInit {
@Input() item!: PlaylistMeta;
readonly showActions = input(true);
readonly isDraggable = input(false);
readonly isSelected = input(false);
readonly editPlaylistClicked = output<PlaylistMeta>();
readonly playlistClicked = output<string>();
readonly refreshClicked = output<PlaylistMeta>();
readonly removeClicked = output<string>();
portalStatus: PortalStatus = 'unavailable';
private readonly portalStatusService = inject(PortalStatusService);
readonly isElectron = !!window.electron;
async ngOnInit() {
await this.checkPortalStatus();
}
private async checkPortalStatus() {
try {
if (
this.item.serverUrl &&
this.item.username &&
this.item.password
) {
this.portalStatus =
await this.portalStatusService.checkPortalStatus(
this.item.serverUrl,
this.item.username,
this.item.password
);
}
} catch (error) {
console.error('Error checking portal status:', error);
this.portalStatus = 'unavailable';
}
}
getStatusClass(): string {
return this.portalStatusService.getStatusClass(this.portalStatus);
}
getStatusIcon(): string {
return this.portalStatusService.getStatusIcon(this.portalStatus);
}
}
@@ -1,63 +0,0 @@
@if (allPlaylistsLoaded()) {
@defer {
@if (playlistsData$ | async; as data) {
@if (data.playlists.length === 0) {
@if (data.totalCount === 0) {
<!-- Welcome state: no playlists at all -->
<app-empty-state
[type]="'welcome'"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- No results state: search/filter returned nothing -->
<app-empty-state [type]="'no-results'" />
}
} @else {
<mat-nav-list
class="playlists-list"
cdkDropList
[cdkDropListDisabled]="!isCustomSortActive()"
(cdkDropListDropped)="drop($event, data.playlists)"
>
@for (
item of data.playlists;
track item._id;
let last = $last
) {
<app-playlist-item
[item]="item"
[isSelected]="activePlaylistId() === item._id"
[showActions]="!sidebarMode()"
[isDraggable]="isCustomSortActive()"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked($event)"
/>
}
</mat-nav-list>
}
}
} @placeholder {
<div id="skeleton-container">
@for (a of ghostElements; track $index) {
<div class="skeleton-item">
<ngx-skeleton-loader count="1" appearance="circle" />
<div class="text">
<ngx-skeleton-loader
count="1"
[theme]="{ width: '30%', height: '20px' }"
/>
<ngx-skeleton-loader
count="1"
[theme]="{ width: '50%', height: '10px' }"
/>
</div>
<div class="actions">
<ngx-skeleton-loader count="2" appearance="circle" />
</div>
</div>
}
</div>
}
}
@@ -1,57 +0,0 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
align-items: center;
}
.mat-list-base {
padding-top: 0;
}
.playlists-list {
padding: 0;
width: 100%;
overflow-x: hidden;
}
.meta {
font-size: 12px !important;
color: #666;
margin-top: 2px !important;
}
// Light theme
@media (prefers-color-scheme: light) {
.meta {
color: #757575;
}
}
#skeleton-container {
padding-top: 10px;
width: 100%;
.skeleton-item {
display: flex;
padding: 0 20px;
height: 72px;
.text {
flex: 1;
align-self: center;
padding-left: 6px;
flex-direction: column;
display: flex;
}
}
}
@media only screen and (max-width: 480px) {
.favorites-icon {
padding-left: 0 !important;
display: none
}
}
@@ -1,130 +0,0 @@
/* import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Actions } from '@ngrx/effects';
import { provideMockActions } from '@ngrx/effects/testing';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { initialPlaylistMetaState } from 'm3u-state';
import { MockComponent, MockModule, MockProvider } from 'ng-mocks';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { Observable } from 'rxjs';
import { DataService } from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import { ElectronServiceStub } from '../../../../../../apps/web/src/app/services/electron.service.stub';
import { DialogService } from '../confirm-dialog/dialog.service';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
import { RecentPlaylistsComponent } from './recent-playlists.component';
describe('RecentPlaylistsComponent', () => {
let component: RecentPlaylistsComponent;
let fixture: ComponentFixture<RecentPlaylistsComponent>;
let electronService: DataService;
let dialog: MatDialog;
let dialogService: DialogService;
let mockStore: MockStore;
const actions$ = new Observable<Actions>();
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
RecentPlaylistsComponent,
MockComponent(PlaylistItemComponent),
MockModule(MatDialogModule),
MockModule(MatDividerModule),
MockModule(MatListModule),
MockModule(MatIconModule),
MockModule(MatInputModule),
MockModule(MatTooltipModule),
MockComponent(NgxSkeletonLoaderComponent),
TranslateModule.forRoot(),
],
providers: [
{ provide: DataService, useClass: ElectronServiceStub },
MockProvider(DialogService),
MatSnackBar,
provideMockStore(),
provideMockActions(actions$),
MockProvider(TranslateService),
],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(RecentPlaylistsComponent);
component = fixture.componentInstance;
dialog = TestBed.inject(MatDialog);
electronService = TestBed.inject(DataService);
dialogService = TestBed.inject(DialogService);
mockStore = TestBed.inject(MockStore);
mockStore.setState({
playlistState: { playlists: initialPlaylistMetaState },
});
console.error = jest.fn();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should open the info dialog', () => {
jest.spyOn(dialog, 'open');
component.openInfoDialog({} as PlaylistMeta);
expect(dialog.open).toHaveBeenCalledTimes(1);
});
it('should send an ipc event after drop event', () => {
const event = {
previousIndex: 0,
currentIndex: 1,
item: undefined,
container: undefined,
previousContainer: undefined,
isPointerOverContainer: true,
distance: { x: 0, y: 0 },
dropPoint: { x: 0, y: 0 },
} as any;
jest.spyOn(mockStore, 'dispatch');
component.drop(event, []);
expect(mockStore.dispatch).toHaveBeenCalledTimes(1);
});
it('should open the confirmation dialog on remove icon click', () => {
const playlistId = '12345';
jest.spyOn(dialogService, 'openConfirmDialog');
component.removeClicked(playlistId);
expect(dialogService.openConfirmDialog).toHaveBeenCalledTimes(1);
});
it('should send an event to the main process to refresh a playlist', () => {
const playlistMeta: PlaylistMeta = {
id: 'iptv1',
title: 'iptv',
filePath: '/home/user/lists/iptv.m3u',
} as unknown as PlaylistMeta;
jest.spyOn(electronService, 'sendIpcEvent');
component.refreshPlaylist(playlistMeta);
expect(electronService.sendIpcEvent).toHaveBeenCalledWith(
PLAYLIST_UPDATE,
{
id: playlistMeta._id,
filePath: playlistMeta.filePath,
title: playlistMeta.title,
}
);
});
it('should send an event to the main process to get a playlist', () => {
const playlistId = '6789';
jest.spyOn(component.playlistClicked, 'emit');
component.getPlaylist({ _id: playlistId } as unknown as PlaylistMeta);
expect(component.playlistClicked.emit).toHaveBeenCalledTimes(1);
});
});
*/
@@ -1,304 +0,0 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import { AsyncPipe } from '@angular/common';
import { Component, effect, inject, input, output } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
PlaylistActions,
selectActivePlaylistId,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from 'm3u-state';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DatabaseService, DataService, SortBy, SortService } from 'services';
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
import { DialogService } from '../confirm-dialog/dialog.service';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
@Component({
selector: 'app-recent-playlists',
templateUrl: './recent-playlists.component.html',
styleUrls: ['./recent-playlists.component.scss'],
imports: [
AsyncPipe,
DragDropModule,
EmptyStateComponent,
MatInputModule,
MatListModule,
NgxSkeletonLoaderComponent,
PlaylistItemComponent,
],
})
export class RecentPlaylistsComponent {
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly dataService = inject(DataService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
readonly sidebarMode = input(false);
readonly searchQueryInput = input<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<PlaylistType>();
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId);
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
);
readonly isCustomSortActive = () =>
this.currentSortOptions().by === SortBy.CUSTOM;
readonly searchQuery = new BehaviorSubject<string>('');
readonly ghostElements = new Array(10);
constructor() {
// Update searchQuery when input changes
effect(() => {
this.searchQuery.next(this.searchQueryInput());
});
}
readonly playlistsData$ = combineLatest([
this.store.select(selectAllPlaylistsMeta),
this.searchQuery,
this.store.select(selectActiveTypeFilters),
this.sortService.getSortOptions(),
]).pipe(
map(([playlists, searchQuery, filters, sortOptions]) => {
const filteredPlaylists = playlists
.filter((item) => {
const isStalkerFilter =
item.macAddress && filters.includes('stalker');
const isXtreamFilter =
item.username &&
item.password &&
item.serverUrl &&
filters.includes('xtream');
const isM3uFilter =
!item.username &&
!item.password &&
!item.serverUrl &&
!item.macAddress &&
filters.includes('m3u');
return (
(isStalkerFilter && filters.includes('stalker')) ||
(isXtreamFilter && filters.includes('xtream')) ||
(isM3uFilter && filters.includes('m3u'))
);
})
.filter((item) =>
(item.title || '')
.toLowerCase()
.includes(searchQuery.toLowerCase())
);
// Apply sorting using the SortService
const sortedPlaylists = this.sortService.sortPlaylists(
filteredPlaylists,
sortOptions
);
return {
playlists: sortedPlaylists,
totalCount: playlists.length,
};
})
);
/**
* Opens the details dialog with the information about the provided playlist
* @param data selected playlist
*/
openInfoDialog(data: PlaylistMeta): void {
this.dialog.open(PlaylistInfoComponent, {
data,
});
}
/**
* Drop event handler - applies the new sort order to the playlists array
* @param event drop event
*/
drop(event: CdkDragDrop<PlaylistMeta[]>, playlists: PlaylistMeta[]): void {
moveItemInArray(playlists, event.previousIndex, event.currentIndex);
this.store.dispatch(
PlaylistActions.updatePlaylistPositions({
positionUpdates: playlists.map((item, index) => ({
id: item._id,
changes: { position: index },
})),
})
);
}
onAddPlaylist(playlistType: PlaylistType) {
this.addPlaylistClicked.emit(playlistType);
}
getPlaylist(playlistMeta: PlaylistMeta): void {
if (playlistMeta.serverUrl) {
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
} else if (playlistMeta.macAddress) {
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
} else {
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
this.playlistClicked.emit(playlistMeta._id);
}
}
/**
* Triggers on remove click
* @param playlistId playlist id to remove
*/
removeClicked(playlistId: string): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: () => {
this.removePlaylist(playlistId);
},
});
}
/**
* Removes the provided playlist from the database
* @param playlistId playlist id to remove
*/
async removePlaylist(playlistId: string) {
const deleted = await this.databaseService.deletePlaylist(playlistId);
if (deleted) {
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
this.snackBar.open(
this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'),
undefined,
{
duration: 2000,
}
);
}
}
/**
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
* @param item playlist to update
*/
refreshPlaylist(item: PlaylistMeta) {
if (item.serverUrl) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(item);
} else {
// For M3U playlists, use existing refresh logic
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
...(item.url ? { url: item.url } : { filePath: item.filePath }),
});
}
}
/**
* Refresh Xtream playlist by deleting all data and re-importing from remote
* @param item Xtream playlist to refresh
*/
async refreshXtreamPlaylist(item: PlaylistMeta) {
this.dialogService.openConfirmDialog({
title: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.TITLE'
),
message: this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.MESSAGE'
),
onConfirm: async () => {
try {
// Show immediate feedback — deletion can take several seconds
// for large playlists.
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
// Delete content/categories and update the timestamp in
// parallel — both operations are fully independent.
const updateDate = Date.now();
const [
{
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
},
] = await Promise.all([
this.databaseService.deleteXtreamPlaylistContent(
item._id
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]);
// Update the timestamp in NgRx / IndexedDB
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
// Persist user data so it can be restored after re-import
const restoreKey = `xtream-restore-${item._id}`;
localStorage.setItem(
restoreKey,
JSON.stringify({
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
})
);
// Navigate to the playlist to trigger re-import
this.router.navigate(['/workspace', 'xtreams', item._id]);
} catch (error) {
console.error('Error refreshing Xtream playlist:', error);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.ERROR'
),
undefined,
{ duration: 3000 }
);
}
},
});
}
}
@@ -2,6 +2,7 @@ import {
Directive,
ElementRef,
Renderer2,
inject,
input,
output,
OnInit,
@@ -78,11 +79,10 @@ export class ResizableDirective implements OnInit, AfterViewInit, OnDestroy {
private boundMouseUp: ((e: MouseEvent) => void) | null = null;
private boundTouchMove: ((e: TouchEvent) => void) | null = null;
private boundTouchEnd: ((e: TouchEvent) => void) | null = null;
private readonly el = inject(ElementRef<HTMLElement>);
private readonly renderer = inject(Renderer2);
constructor(
private readonly el: ElementRef<HTMLElement>,
private readonly renderer: Renderer2
) {
constructor() {
// Apply resizing class when dragging
effect(() => {
if (this.isResizing()) {
@@ -1,12 +0,0 @@
@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>
}
@@ -1,32 +0,0 @@
.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;
}
}
@@ -1,67 +0,0 @@
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();
}
}
}
@@ -1,92 +0,0 @@
@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>
}
@@ -1,675 +0,0 @@
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);
}
}
@@ -1,23 +0,0 @@
@if (showPlaylistHeader()) {
<div class="current-playlist">
<button
mat-icon-button
routerLink="/"
[matTooltip]="'SETTINGS.BACK_TO_HOME' | translate"
class="home-btn"
>
<mat-icon>home</mat-icon>
</button>
<app-playlist-switcher
class="switcher"
[currentTitle]="playlistTitle()"
[subtitle]="subtitle()"
/>
</div>
}
<app-channel-list-container
[channelList]="channels()"
[activeView]="activeView()"
/>
@@ -1,22 +0,0 @@
.current-playlist {
display: flex;
align-items: center;
padding: 8px;
gap: 4px;
app-region: drag;
button {
app-region: no-drag;
}
.switcher {
flex: 1;
min-width: 0;
}
}
@media only screen and (max-width: 480px) {
.current-playlist {
padding: 6px;
}
}
@@ -1,41 +0,0 @@
import { Component, computed, inject, input } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { selectPlaylistTitle } from 'm3u-state';
import { Channel } from 'shared-interfaces';
import { PlaylistSwitcherComponent } from '../../playlist-switcher/playlist-switcher.component';
import { ChannelListContainerComponent } from './../../channel-list-container/channel-list-container.component';
@Component({
selector: 'app-sidebar',
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.scss'],
imports: [
ChannelListContainerComponent,
MatIcon,
MatIconButton,
MatTooltip,
PlaylistSwitcherComponent,
RouterLink,
TranslatePipe,
],
})
export class SidebarComponent {
readonly channels = input<Channel[]>([]);
readonly showPlaylistHeader = input(true);
readonly activeView = input<string>('all');
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle);
readonly subtitle = computed(() => {
const count = this.channels()?.length ?? 0;
return `${count} ${this.translate.instant('HOME.PLAYLISTS.CHANNELS')}`;
});
}
@@ -1,58 +0,0 @@
<mat-toolbar color="primary">
<button
mat-icon-button
(click)="toggleLeftDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_CHANNELS_LIST' | translate"
class="sidenav-toggle-button"
>
<mat-icon>{{ isLeftDrawerOpened ? 'menu' : 'menu_open' }}</mat-icon>
</button>
<!-- @if (playlistId$ | async) {
<button
mat-icon-button
(click)="updateFavoriteStatus(activeChannel)"
[matTooltip]="'TOP_MENU.TOGGLE_FAVORITE_FLAG' | translate"
>
<mat-icon>
{{
(favorites$ | async).includes(activeChannel?.url)
? 'star'
: 'star_outline'
}}</mat-icon
>
</button>
} -->
<button
mat-icon-button
(click)="infoOverlayClicked.emit()"
[matTooltip]="'TOP_MENU.SHOW_INFO' | translate"
>
<mat-icon>info</mat-icon>
</button>
{{ activeChannel?.name }}
<div class="spacer"></div>
<!-- <button
mat-icon-button
(click)="settingsClicked.emit()"
[matTooltip]="'MENU.SETTINGS' | translate"
>
<mat-icon>settings</mat-icon>
</button> -->
<button
mat-icon-button
(click)="multiEpgClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_MULTI_EPG' | translate"
>
<mat-icon>view_list</mat-icon>
</button>
@let isEpgAvailable = isEpgAvailable$ | async;
@if (isEpgAvailable) {
<button
mat-button
(click)="toggleRightDrawerClicked.emit()"
[matTooltip]="'TOP_MENU.OPEN_EPG_LIST' | translate"
>
EPG
</button>
}
</mat-toolbar>
@@ -1,26 +0,0 @@
mat-toolbar {
app-region: drag;
button {
app-region: no-drag;
}
}
.spacer {
flex: 1 1 auto;
}
.mat-toolbar.mat-primary {
background: var(--mdc-primary-100);
color: #ccc;
button {
color: #ccc;
}
}
@media only screen and (max-width: 599px) {
.sidenav-toggle-button {
display: none;
}
}
@@ -1,51 +0,0 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, Input, output } from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import {
FavoritesActions,
selectActivePlaylistId,
selectFavorites,
selectIsEpgAvailable,
} from 'm3u-state';
import { Channel } from 'shared-interfaces';
@Component({
imports: [
AsyncPipe,
MatButton,
MatDialogModule,
MatIcon,
MatIconButton,
MatToolbarModule,
MatTooltip,
TranslatePipe,
],
selector: 'app-toolbar',
templateUrl: './toolbar.component.html',
styleUrls: ['./toolbar.component.scss'],
})
export class ToolbarComponent {
@Input() activeChannel!: Channel;
@Input() isLeftDrawerOpened = true;
readonly infoOverlayClicked = output<void>();
readonly multiEpgClicked = output<void>();
readonly settingsClicked = output<void>();
readonly toggleLeftDrawerClicked = output<void>();
readonly toggleRightDrawerClicked = output<void>();
private readonly store = inject(Store);
readonly favorites$ = this.store.select(selectFavorites);
readonly isEpgAvailable$ = this.store.select(selectIsEpgAvailable);
readonly playlistId$ = this.store.select(selectActivePlaylistId);
updateFavoriteStatus(channel: Channel) {
this.store.dispatch(FavoritesActions.updateFavorites({ channel }));
}
}
@@ -1,9 +0,0 @@
<video
#target
class="video-js"
controls
muted
playsinline
preload="none"
data-setup='{"liveui": true, "muted": false}'
></video>
@@ -1,26 +0,0 @@
:host {
display: block;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.video-js {
position: relative !important;
width: 100% !important;
height: 100% !important;
}
/* Hide the captions settings item from the captions menu. */
.hide-captions {
.vjs-texttrack-settings,
.vjs-text-track-display,
.vjs-subs-caps-button {
display: none;
}
}
.video-js .vjs-menu-button-popup .vjs-menu {
z-index: 10;
}
@@ -1,275 +0,0 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import '@yangkghjh/videojs-aspect-ratio-panel';
import { getExtensionFromUrl } from 'm3u-utils';
import mpegts from 'mpegts.js';
import videoJs from 'video.js';
import 'videojs-contrib-quality-levels';
import 'videojs-quality-selector-hls';
/**
* This component contains the implementation of video player that is based on video.js library
*/
@Component({
selector: 'app-vjs-player',
templateUrl: './vjs-player.component.html',
styleUrls: ['./vjs-player.component.scss'],
encapsulation: ViewEncapsulation.None,
standalone: true,
})
export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy {
/** DOM-element reference */
@ViewChild('target', { static: true }) target!: ElementRef<Element>;
/** Options of VideoJs player */
@Input() options!: any;
/** VideoJs object */
player!: any;
/** mpegts.js player for raw MPEG-TS streams */
private mpegtsPlayer: mpegts.Player | null = null;
@Input() volume = 1;
@Input() startTime = 0;
@Output() timeUpdate = new EventEmitter<{
currentTime: number;
duration: number;
}>();
/**
* Instantiate Video.js on component init
*/
ngOnInit(): void {
const source = this.options?.sources?.[0];
const isMpegTs = this.isMpegTsSource(source?.src);
// For raw MPEG-TS streams, init Video.js without a source (UI/controls only)
const vjsOptions = isMpegTs
? { ...this.options, sources: [], autoplay: false }
: { ...this.options, autoplay: true };
this.player = videoJs(
this.target.nativeElement,
vjsOptions,
() => {
console.log(
'Setting VideoJS player initial volume to:',
this.volume
);
this.player.volume(this.volume);
this.player.on('loadedmetadata', () => {
if (this.startTime > 0) {
this.player.currentTime(this.startTime);
}
this.logAudioTracks();
this.setupAudioTrackMenu();
});
// Audio tracks may be added after loadedmetadata (e.g. HLS alternate audio)
const audioTracks = this.player.audioTracks();
if (audioTracks) {
audioTracks.addEventListener('addtrack', () => {
console.log(
'[AudioTrack] addtrack event fired, total tracks:',
this.player.audioTracks().length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
});
audioTracks.addEventListener('removetrack', () => {
console.log(
'[AudioTrack] removetrack event fired, total tracks:',
this.player.audioTracks().length
);
this.logAudioTracks();
this.setupAudioTrackMenu();
});
audioTracks.addEventListener('change', () => {
this.logAudioTracks();
});
}
this.player.on('volumechange', () => {
const currentVolume = this.player.volume();
localStorage.setItem('volume', currentVolume.toString());
});
this.player.on('timeupdate', () => {
this.timeUpdate.emit({
currentTime: this.player.currentTime(),
duration: this.player.duration(),
});
});
// Attach mpegts.js after Video.js is ready
if (isMpegTs) {
this.initMpegTs(source.src);
}
}
);
try {
if (typeof this.player.qualitySelectorHls === 'function') {
this.player.qualitySelectorHls({
displayCurrentQuality: true,
});
}
} catch (e) {
console.warn('qualitySelectorHls plugin failed to initialize:', e);
}
try {
if (typeof this.player['aspectRatioPanel'] === 'function') {
this.player['aspectRatioPanel']();
}
} catch (e) {
console.warn('aspectRatioPanel plugin failed to initialize:', e);
}
}
/**
* Replaces the url source of the player with the changed source url
* @param changes contains changed channel object
*/
ngOnChanges(changes: SimpleChanges): void {
if (changes['options']?.previousValue) {
const newSource = changes['options'].currentValue.sources[0];
if (this.isMpegTsSource(newSource?.src)) {
this.destroyMpegTs();
this.player.reset();
this.initMpegTs(newSource.src);
} else {
this.destroyMpegTs();
this.player.src(newSource);
}
}
if (changes['volume']?.currentValue !== undefined && this.player) {
console.log(
'Setting VideoJS player volume to:',
changes['volume'].currentValue
);
this.player.volume(changes['volume'].currentValue);
}
}
/**
* Removes the players HTML reference on destroy
*/
ngOnDestroy(): void {
this.destroyMpegTs();
if (this.player) {
this.player.dispose();
}
}
private isMpegTsSource(url?: string): boolean {
if (!url) return false;
return getExtensionFromUrl(url) === 'ts' && mpegts.isSupported();
}
private initMpegTs(url: string): void {
const videoEl = this.player.tech({ IWillNotUseThisInPlugins: true })?.el();
if (!videoEl) return;
console.log('Using mpegts.js for TS stream:', url);
this.mpegtsPlayer = mpegts.createPlayer({
type: 'mpegts',
isLive: true,
url: url,
});
this.mpegtsPlayer.attachMediaElement(videoEl as HTMLVideoElement);
this.mpegtsPlayer.load();
this.mpegtsPlayer.play();
}
private destroyMpegTs(): void {
if (this.mpegtsPlayer) {
this.mpegtsPlayer.pause();
this.mpegtsPlayer.unload();
this.mpegtsPlayer.detachMediaElement();
this.mpegtsPlayer.destroy();
this.mpegtsPlayer = null;
}
}
/**
* Logs all available audio tracks for debugging.
*/
private logAudioTracks(): void {
const audioTracks = this.player.audioTracks();
console.log('[AudioTrack] Audio tracks count:', audioTracks?.length ?? 0);
for (let i = 0; i < (audioTracks?.length ?? 0); i++) {
const t = audioTracks[i];
console.log(
`[AudioTrack] Track ${i}: label="${t.label}", language="${t.language}", enabled=${t.enabled}, kind="${t.kind}"`
);
}
// Also check the underlying tech for HLS audio tracks
const tech =
typeof this.player?.tech === 'function'
? this.player.tech({ IWillNotUseThisInPlugins: true })
: null;
const audioMediaGroups =
tech?.vhs?.playlists?.main?.mediaGroups?.AUDIO ??
tech?.vhs?.playlists?.master?.mediaGroups?.AUDIO;
if (audioMediaGroups) {
console.log(
'[AudioTrack] HLS AUDIO media groups:',
JSON.stringify(Object.keys(audioMediaGroups))
);
} else {
console.log(
'[AudioTrack] HLS AUDIO media groups: none found in playlist metadata'
);
}
}
/**
* Sets up the audio track selection menu in the control bar.
* Uses the Video.js audioTracks() API which works with both
* native multi-audio streams and HLS.js alternate audio tracks.
*/
private setupAudioTrackMenu(): void {
const audioTracks = this.player.audioTracks();
console.log(
'[AudioTrack] setupAudioTrackMenu called, tracks:',
audioTracks?.length ?? 0
);
if (!audioTracks || audioTracks.length <= 1) {
console.log(
'[AudioTrack] Skipping menu: need >1 tracks, have',
audioTracks?.length ?? 0
);
console.log(
'[AudioTrack] If VLC/MPV show more tracks, the HLS manifest likely does not expose alternate audio via EXT-X-MEDIA'
);
return;
}
const controlBar = this.player.getChild('controlBar');
if (!controlBar) {
return;
}
let audioButton =
controlBar.getChild('audioTrackButton') ??
controlBar.getChild('AudioTrackButton');
if (!audioButton) {
audioButton = controlBar.addChild('audioTrackButton', {});
}
if (audioButton) {
audioButton.show?.();
audioButton.update?.();
}
}
}
@@ -1,164 +0,0 @@
@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>
@@ -1,312 +0,0 @@
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);
}
}
}
@@ -1,27 +0,0 @@
@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)"
/>
}
@@ -1,11 +0,0 @@
:host {
display: block;
height: 100%;
}
app-art-player,
app-vjs-player,
app-html-video-player {
display: block;
height: 100%;
}
@@ -1,72 +0,0 @@
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,
};
}
}