mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
aa739f43e0
commit
7ac2fd66b8
175 files changed
+3118
-1076
No files matched your search
@@ -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 }));
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -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 */
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
-63
@@ -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>
|
||||
-300
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-67
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
-65
@@ -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();
|
||||
}
|
||||
}
|
||||
-77
@@ -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);
|
||||
}
|
||||
}
|
||||
-193
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-162
@@ -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>
|
||||
-215
@@ -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;
|
||||
}
|
||||
}
|
||||
-43
@@ -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()) {
|
||||
|
||||
-12
@@ -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>
|
||||
}
|
||||
-32
@@ -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;
|
||||
}
|
||||
}
|
||||
-67
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user