refactor: update nx monorepo structure

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

No files matched your search

+5 -3
View File
@@ -5,6 +5,11 @@ import { Router, RouterOutlet } from '@angular/router';
import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import {
GlobalRecentlyViewedComponent,
GlobalSearchResultsComponent,
} from '@iptvnator/portal/xtream/feature';
import { EpgProgressPanelComponent } from 'components';
import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state';
import { filter, take } from 'rxjs';
import { DataService, EpgService } from 'services';
@@ -17,9 +22,6 @@ import {
Theme,
} from 'shared-interfaces';
import { SettingsService } from './services/settings.service';
import { EpgProgressPanelComponent } from './shared/epg-progress-panel/epg-progress-panel.component';
import { GlobalRecentlyViewedComponent } from './xtream-electron/recently-viewed/global-recently-viewed.component';
import { GlobalSearchResultsComponent } from './xtream-electron/search-results/global-search-results.component';
@Component({
selector: 'app-root',
+22 -2
View File
@@ -20,16 +20,26 @@ import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { PlaylistEffects, playlistReducer } from 'm3u-state';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/m3u/feature-player';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from 'services';
import { dbConfig } from 'shared-interfaces';
import { AppConfig } from '../environments/environment';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.service';
import { PlayerService } from './services/player.service';
import { providePortalNavigationActions } from './services/portal-navigation-actions.service';
import {
AppPortalNavigationActionsService,
providePortalNavigationActions,
} from './services/portal-navigation-actions.service';
import { providePortalPlaybackPositions } from './services/portal-playback-positions.service';
import { PwaService } from './services/pwa.service';
import { provideWorkspaceShellActions } from './services/workspace-shell-actions.service';
// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
@@ -92,7 +102,17 @@ export const appConfig: ApplicationConfig = {
provide: PORTAL_PLAYER,
useExisting: PlayerService,
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useExisting: ExternalPlaybackService,
},
...providePortalPlaybackPositions(),
...providePortalNavigationActions(),
{
provide: PLAYLIST_PLAYER_ACTIONS,
useExisting: AppPortalNavigationActionsService,
},
...provideWorkspaceShellActions(),
...provideXtreamDataSource(),
],
};
+6 -12
View File
@@ -1,8 +1,6 @@
import { Route, Routes } from '@angular/router';
import { createStalkerRoutes } from '@iptvnator/portal/stalker/feature';
import { createXtreamRoutes } from '@iptvnator/portal/xtream/feature';
import { stalkerFeatureRouteOptions } from './stalker/stalker-feature-loaders';
import { xtreamFeatureRouteOptions } from './xtream-electron/xtream-feature-loaders';
function withWorkspaceLayout(routes: Route[]): Route[] {
return routes.map((route) => ({
@@ -26,7 +24,7 @@ export const routes: Routes = [
{
path: 'workspace',
loadComponent: () =>
import('./workspace/workspace-shell.component').then(
import('@iptvnator/workspace/shell/feature').then(
(c) => c.WorkspaceShellComponent
),
children: [
@@ -45,7 +43,7 @@ export const routes: Routes = [
{
path: 'sources',
loadComponent: () =>
import('./workspace/workspace-sources.component').then(
import('@iptvnator/workspace/shell/feature').then(
(c) => c.WorkspaceSourcesComponent
),
},
@@ -60,7 +58,7 @@ export const routes: Routes = [
layout: 'workspace',
},
loadComponent: () =>
import('./home/video-player/video-player.component').then(
import('@iptvnator/playlist/m3u/feature-player').then(
(c) => c.VideoPlayerComponent
),
},
@@ -70,7 +68,7 @@ export const routes: Routes = [
layout: 'workspace',
},
loadComponent: () =>
import('./workspace/global-favorites/global-favorites-page.component').then(
import('@iptvnator/workspace/shell/feature').then(
(c) => c.GlobalFavoritesPageComponent
),
},
@@ -82,12 +80,8 @@ export const routes: Routes = [
(c) => c.DownloadsComponent
),
},
...withWorkspaceLayout(
createXtreamRoutes(xtreamFeatureRouteOptions)
),
...withWorkspaceLayout(
createStalkerRoutes(stalkerFeatureRouteOptions)
),
...withWorkspaceLayout(createXtreamRoutes()),
...withWorkspaceLayout(createStalkerRoutes()),
{
path: 'settings',
data: { layout: 'workspace' },
@@ -1 +0,0 @@
@forward '../../../../../libs/portal/shared/ui/src/lib/styles/portal-main-container';
@@ -1,450 +0,0 @@
import { KeyValuePipe } from '@angular/common';
import { Component, input, inject, output, signal, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
ProgressCapsuleComponent,
WatchedBadgeComponent,
} from '@iptvnator/portal/shared/ui';
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { DownloadsService } from '../../services/downloads.service';
import { createLogger } from '@iptvnator/portal/shared/util';
type EpisodeViewMode = 'grid' | 'list';
const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode';
@Component({
selector: 'app-season-container',
templateUrl: './season-container.component.html',
styleUrls: ['./season-container.component.scss'],
imports: [
KeyValuePipe,
MatButtonModule,
MatButtonToggleModule,
MatIcon,
MatProgressSpinnerModule,
MatTooltipModule,
ProgressCapsuleComponent,
TranslateModule,
WatchedBadgeComponent,
],
})
export class SeasonContainerComponent implements OnInit {
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('SeasonContainer');
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>();
readonly seriesTitle = input<string>('');
readonly isLoading = input<boolean>(false);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly isElectron = this.downloadsService.isAvailable;
/** Current view mode for episodes (grid or list) */
readonly viewMode = signal<EpisodeViewMode>('grid');
ngOnInit() {
// Load saved view mode preference
const savedMode = localStorage.getItem(EPISODE_VIEW_MODE_KEY) as EpisodeViewMode;
if (savedMode === 'grid' || savedMode === 'list') {
this.viewMode.set(savedMode);
}
}
/** Toggle between grid and list view modes */
setViewMode(mode: EpisodeViewMode) {
this.viewMode.set(mode);
localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode);
}
/**
* Detect if episode is from Stalker portal based on custom_sid marker
*/
private isStalkerEpisode(episode: XtreamSerieEpisode): boolean {
return (
(episode as any).custom_sid === 'vod-series' ||
(episode as any).custom_sid === 'regular-series'
);
}
/**
* Get the appropriate playlist ID based on mode
* Note: XtreamStore uses 'id', StalkerStore uses '_id'
*/
private getPlaylistId(episode: XtreamSerieEpisode): string | undefined {
if (this.isStalkerEpisode(episode)) {
return this.stalkerStore.currentPlaylist()?._id;
}
return this.xtreamStore.currentPlaylist()?.id;
}
/**
* Get the content ID for playback position lookup.
* For Stalker VOD series, use the generated unique ID (episode.id).
* The API-provided originalId is the same for all episodes (it's the series ID),
* so we use the hash-generated ID which is unique per episode.
* For Xtream episodes, use episode.id directly.
*/
private getEpisodeContentId(episode: XtreamSerieEpisode): number {
const customSid = (episode as any).custom_sid;
if (customSid === 'vod-series') {
// For vod-series, use the generated unique ID (episode.id)
// NOT originalId, which is the same for all episodes (series ID)
return Number(episode.id);
}
if (customSid === 'regular-series') {
// For regular-series, the hashed id is used for tracking
// (regular series don't have individual episode IDs from API)
return Number(episode.id);
}
// For xtream (non-Stalker), use episode.id directly
return Number(episode.id);
}
/**
* Get a stable numeric ID for an episode (used for downloads tracking)
* For Stalker episodes, use originalCmd/originalId; for Xtream use episode.id
*/
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as any).custom_sid;
if (customSid === 'regular-series') {
// For regular-series, use originalCmd (like "/media/file_123.mpg")
const cmd = (episode as any).originalCmd as string;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
// Fallback: hash the cmd string to get a consistent number
return this.hashString(cmd);
}
// Final fallback: use the hashed id
return Number(episode.id);
}
if (customSid === 'vod-series') {
// For vod-series, use originalId from the API
const originalId = (episode as any).originalId;
const numId = Number(originalId);
return isNaN(numId) ? this.hashString(String(originalId)) : numId;
}
// For xtream (non-Stalker), use the numeric id directly
const numId = Number(episode.id);
return isNaN(numId) ? this.hashString(String(episode.id)) : numId;
}
/**
* Simple string hash function to generate consistent numeric IDs
*/
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
readonly episodeClicked = output<any>();
readonly seasonSelected = output<string>();
selectedSeason: string;
compareSeasons(a: any, b: any): number {
return Number(a.key) - Number(b.key);
}
selectSeason(seasonKey: string) {
this.selectedSeason = seasonKey;
this.seasonSelected.emit(seasonKey);
}
selectEpisode(episode: XtreamSerieEpisode) {
this.episodeClicked.emit(episode);
}
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
const playlistId = this.getPlaylistId(episode);
if (!playlistId) {
this.logger.warn('Cannot toggle watched: no playlist ID');
return;
}
// For Stalker episodes, we need to use the content ID that matches
// what the player uses (originalId for vod-series)
const contentId = this.getEpisodeContentId(episode);
const episodeWithCorrectId = {
...episode,
id: String(contentId),
} as XtreamSerieEpisode;
this.xtreamStore.toggleEpisodeWatched(
playlistId,
episodeWithCorrectId,
this.seriesId()
);
}
/**
* Safely get episode info - returns undefined if info is an empty array
* (Xtream API returns [] when no metadata available instead of null/object)
*/
getEpisodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
if (Array.isArray(episode.info) || !episode.info) {
return undefined;
}
return episode.info;
}
isEpisodeWatched(episode: XtreamSerieEpisode) {
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.isWatched(contentId, 'episode');
}
isEpisodeInProgress(episode: XtreamSerieEpisode) {
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.isInProgress(contentId, 'episode');
}
isEpisodeLaunching(episode: XtreamSerieEpisode): boolean {
return this.openingEpisodeId() === this.getEpisodeContentId(episode);
}
isEpisodeActiveExternal(episode: XtreamSerieEpisode): boolean {
return this.activeEpisodeId() === this.getEpisodeContentId(episode);
}
getEpisodeProgress(episode: XtreamSerieEpisode) {
const contentId = this.getEpisodeContentId(episode);
return this.xtreamStore.getProgressPercent(contentId, 'episode');
}
getEpisodePositionText(episode: XtreamSerieEpisode): string | null {
if (this.isEpisodeWatched(episode)) return null;
const contentId = this.getEpisodeContentId(episode);
const position = this.xtreamStore
.playbackPositions()
.get(`episode_${contentId}`);
if (!position || !position.positionSeconds) return null;
let seconds = position.positionSeconds;
let suffix = '';
// Calculate remaining time if duration is available
if (position.durationSeconds > 0) {
const remaining = Math.max(
0,
position.durationSeconds - position.positionSeconds
);
if (remaining <= 0) return null;
seconds = remaining;
suffix = ' left';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const formatted = [hours, minutes, secs]
.map((n) => String(n).padStart(2, '0'))
.filter((v, i) => (i === 0 ? v !== '00' : true))
.join(':');
return `${formatted}${suffix}`;
}
getSeasonWatchedCount(seasonKey: string): number {
const episodes = this.seasons()[seasonKey];
if (!episodes) return 0;
return episodes.filter((e) => this.isEpisodeWatched(e)).length;
}
getSeasonProgressDash(seasonKey: string): string {
const episodes = this.seasons()[seasonKey];
if (!episodes || episodes.length === 0) return '0, 100';
const watched = this.getSeasonWatchedCount(seasonKey);
const percent = (watched / episodes.length) * 100;
return `${percent}, 100`;
}
async downloadEpisode(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
// Handle Stalker mode
if (this.isStalkerEpisode(episode)) {
await this.downloadStalkerEpisode(episode);
return;
}
// Xtream mode
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) return;
// Construct the episode stream URL
const serverUrl = playlist.serverUrl?.replace(/\/$/, '') || '';
const username = playlist.username || '';
const password = playlist.password || '';
const extension = episode.container_extension || 'mp4';
const url = `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`;
// Get episode info for poster
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
// Build episode title
const seriesName = this.seriesTitle() || 'Series';
const seasonNum = episode.season || Number(this.selectedSeason) || 1;
const episodeNum = episode.episode_num || 1;
const episodeTitle = `${seriesName} - S${String(seasonNum).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: Number(episode.id),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
seriesXtreamId: this.seriesId(),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
});
}
/**
* Download episode from Stalker portal
*/
private async downloadStalkerEpisode(episode: XtreamSerieEpisode) {
const playlist = this.stalkerStore.currentPlaylist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
const customSid = (episode as any).custom_sid;
let cmd: string;
if (customSid === 'vod-series') {
// VOD series episode - use originalId for the cmd
const originalId = (episode as any).originalId;
cmd = `/media/file_${originalId}.mpg`;
} else {
// Regular series - use originalCmd for the cmd
cmd = (episode as any).originalCmd as string;
}
// Resolve the actual stream URL via Stalker API
// The cmd is a reference that needs to be resolved to get the tokenized URL
let url: string;
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd,
episode.episode_num // series param for episode number
);
if (!url) {
this.logger.error('Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
this.logger.error('Error resolving Stalker stream URL', error);
return;
}
// Get episode info for poster
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
// Build episode title
const selectedItem = this.stalkerStore.selectedItem();
const seriesName = selectedItem?.info?.name || this.seriesTitle() || 'Series';
const seasonNum = Number(this.selectedSeason) || 1;
const episodeNum = episode.episode_num || 1;
const episodeTitle = `${seriesName} - 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.seriesId(),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
// Playlist info for auto-creation (Stalker playlists)
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
/** Check if episode is already downloaded */
isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean {
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloaded(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
}
/** Check if episode is currently downloading */
isEpisodeDownloading(episode: XtreamSerieEpisode): boolean {
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return false;
// Access downloads signal to make this reactive
this.downloadsService.downloads();
return this.downloadsService.isDownloading(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
}
/** Play episode from local file */
async playFromLocal(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
const playlistId = this.getPlaylistId(episode);
if (!playlistId) return;
const filePath = this.downloadsService.getDownloadedFilePath(
this.getEpisodeDownloadId(episode),
playlistId,
'episode'
);
if (filePath) {
await this.downloadsService.playDownload(filePath);
}
}
}
+7 -6
View File
@@ -1,5 +1,10 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ExternalPlayerInfoDialogComponent } from 'components';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '@iptvnator/portal/xtream/feature';
import { DataService } from 'services';
import {
ExternalPlayerSession,
@@ -9,11 +14,6 @@ import {
ResolvedPortalPlayback,
VideoPlayer,
} from 'shared-interfaces';
import { ExternalPlayerInfoDialogComponent } from '../shared/components/external-player-info-dialog/external-player-info-dialog.component';
import {
PlayerDialogComponent,
PlayerDialogData,
} from '../xtream-electron/player-dialog/player-dialog.component';
import { SettingsStore } from './settings-store.service';
@Injectable({
@@ -39,7 +39,7 @@ export class PlayerService {
title: string,
thumbnail?: string,
hideExternalInfoDialog = true,
isLiveContent = false,
_isLiveContent = false,
userAgent?: string,
referer?: string,
origin?: string,
@@ -47,6 +47,7 @@ export class PlayerService {
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession | void> {
void _isLiveContent;
return this.openResolvedPlayback(
{
streamUrl,
@@ -5,10 +5,10 @@ import {
PORTAL_NAVIGATION_ACTIONS,
PortalNavigationActions,
} from '@iptvnator/portal/shared/util';
import { AccountInfoComponent } from '@iptvnator/portal/xtream/feature';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { Playlist } from 'shared-interfaces';
import { SettingsComponent } from '../settings/settings.component';
import { AccountInfoComponent } from '../xtream-electron/account-info/account-info.component';
@Injectable({ providedIn: 'root' })
export class AppPortalNavigationActionsService
@@ -0,0 +1,72 @@
import { Injectable, inject } from '@angular/core';
import {
PORTAL_PLAYBACK_POSITIONS,
PortalPlaybackPositions,
} from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XTREAM_DATA_SOURCE,
} from '@iptvnator/portal/xtream/data-access';
@Injectable({
providedIn: 'root',
})
export class AppPortalPlaybackPositionsService
implements PortalPlaybackPositions
{
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
async savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void> {
await this.dataSource.savePlaybackPosition(playlistId, data);
}
async getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null> {
return this.dataSource.getPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
async getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]> {
return this.dataSource.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
}
async getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]> {
return this.dataSource.getAllPlaybackPositions(playlistId);
}
async clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void> {
await this.dataSource.clearPlaybackPosition(
playlistId,
contentXtreamId,
contentType
);
}
}
export const providePortalPlaybackPositions = () => [
{
provide: PORTAL_PLAYBACK_POSITIONS,
useExisting: AppPortalPlaybackPositionsService,
},
];
@@ -0,0 +1,70 @@
import { inject, Injectable, Provider } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { AddPlaylistDialogComponent } from '@iptvnator/playlist/import/feature';
import {
AccountInfoComponent,
GlobalRecentlyViewedComponent,
GlobalSearchResultsComponent,
} from '@iptvnator/portal/xtream/feature';
import { PlaylistType } from 'components';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceAccountInfoData,
WorkspaceShellActions,
} from '@iptvnator/workspace/shell/feature';
@Injectable({ providedIn: 'root' })
export class AppWorkspaceShellActionsService implements WorkspaceShellActions {
private readonly dialog = inject(MatDialog);
openAddPlaylistDialog(type: PlaylistType): void {
this.dialog.open(AddPlaylistDialogComponent, {
width: '600px',
data: { type },
});
}
openGlobalSearch(initialQuery = ''): void {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: {
isGlobalSearch: true,
initialQuery,
},
});
}
openGlobalRecent(): void {
this.dialog.open(GlobalRecentlyViewedComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobal: true },
hasBackdrop: true,
disableClose: false,
});
}
openAccountInfo(data: WorkspaceAccountInfoData): void {
this.dialog.open(AccountInfoComponent, {
width: '80%',
maxWidth: '1200px',
maxHeight: '90vh',
data,
});
}
}
export function provideWorkspaceShellActions(): Provider[] {
return [
AppWorkspaceShellActionsService,
{
provide: WORKSPACE_SHELL_ACTIONS,
useExisting: AppWorkspaceShellActionsService,
},
];
}
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/await-thenable */
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import {
@@ -20,29 +19,27 @@ import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
MockComponent,
MockModule,
MockProvider,
MockProviders,
} from 'ng-mocks';
import { DialogService } from 'components';
import { DataService, EpgService, PlaylistsService } from 'services';
import { Language, StreamFormat, Theme, VideoPlayer } from 'shared-interfaces';
import { DataService } from '../../../../../libs/services/src/lib/data.service';
import { HeaderComponent } from '../shared/components';
import { SettingsComponent } from './settings.component';
import { signal } from '@angular/core';
import { NgxIndexedDBService } from 'ngx-indexed-db';
import { of } from 'rxjs';
import { SETTINGS_UPDATE } from 'shared-interfaces';
import { EpgService } from '../../../../../libs/services/src/lib/epg.service';
import { PlaylistsService } from '../../../../../libs/services/src/lib/playlists.service';
import { DialogService } from '../../../../../libs/ui/components/src/lib/confirm-dialog/dialog.service';
import { ElectronServiceStub } from '../services/electron.service.stub';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
class MatSnackBarStub {
open() {}
open() {
return undefined;
}
}
export class MockRouter {
@@ -74,8 +71,11 @@ class MockSettingsStore {
updateSettings = jest.fn().mockResolvedValue(undefined);
// Helper method for tests to modify settings
_setSettings(newSettings: any) {
this._settings.set(newSettings);
_setSettings(newSettings: Partial<typeof DEFAULT_SETTINGS>) {
this._settings.set({
...this._settings(),
...newSettings,
});
}
}
@@ -89,7 +89,7 @@ describe('SettingsComponent', () => {
let fixture: ComponentFixture<SettingsComponent>;
let electronService: DataService;
let router: Router;
let settingsStore: any;
let settingsStore: MockSettingsStore;
let translate: TranslateService;
let epgService: EpgService;
@@ -112,7 +112,6 @@ describe('SettingsComponent', () => {
],
imports: [
SettingsComponent,
MockComponent(HeaderComponent),
HttpClientTestingModule,
FormsModule,
MockModule(MatSelectModule),
@@ -49,8 +49,8 @@ import {
Theme,
VideoPlayer,
} from 'shared-interfaces';
import { SettingsContextService } from '@iptvnator/workspace/shell/feature';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsContextService } from '../workspace/settings-context.service';
import { SettingsService } from './../services/settings.service';
interface SettingsSection {
@@ -1,70 +0,0 @@
import { Component, inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
@Component({
imports: [MatIconModule, TranslateModule],
template: `
<div mat-dialog-content>
<img
src="./assets/icons/icon-tv-256.png"
width="128"
alt="logo"
/><br />
<h2 mat-dialog-title>{{ 'ABOUT.TITLE' | translate }}</h2>
<p>{{ 'ABOUT.DESCRIPTION' | translate }}</p>
<p>{{ 'ABOUT.VERSION' | translate }}: {{ appVersion }}</p>
<p>
<a
href="https://github.com/4gray/iptvnator"
target="_blank"
[title]="'ABOUT.GITHUB_TOOLTIP' | translate"
[attr.aria-label]="'ABOUT.GITHUB_TOOLTIP' | translate"
><img
src="./assets/icons/github-light.png"
[title]="'ABOUT.GITHUB_TOOLTIP' | translate"
alt="GitHub" /></a
>&nbsp;
<a
href="http://twitter.com/share?text=IPTVnator &mdash; free cross-platform IPTV player. Available as PWA and as native application.&url=https://github.com/4gray/iptvnator&hashtags=iptv,m3u,video-player"
[title]="'ABOUT.TWITTER_TOOLTIP' | translate"
>
<img
height="32"
src="./assets/icons/twitter-light.png"
[title]="'ABOUT.TWITTER_TOOLTIP' | translate"
alt="Twitter"
/>
</a>
<a
href="https://github.com/sponsors/4gray"
target="_blank"
[title]="'ABOUT.BUY_ME_A_COFFEE_TOOLTIP' | translate"
[attr.aria-label]="
'ABOUT.BUY_ME_A_COFFEE_TOOLTIP' | translate
"
><mat-icon>local_cafe</mat-icon></a
>
</p>
</div>
`,
styles: [
`
button {
text-transform: uppercase;
}
a {
color: #fff;
}
.mat-icon {
font-size: 32px;
}
`,
],
})
export class AboutDialogComponent {
public appVersion = inject<string>(MAT_DIALOG_DATA);
}
@@ -1,265 +0,0 @@
<div class="modern-header">
<div class="brand-block">
<div class="logo-pulse">
<img src="./assets/icons/icon-tv-256.png" alt="iptvnator logo" />
</div>
<div class="brand-text">
<div class="title">{{ title() }}</div>
</div>
</div>
@if (isHome) {
<div class="search-container">
<mat-icon class="search-icon">search</mat-icon>
<input
#searchInput
type="search"
class="search-input"
spellcheck="false"
autocomplete="off"
[placeholder]="'HOME.PLAYLISTS.SEARCH_PLAYLISTS' | translate"
(input)="onSearchQueryUpdate(searchInput.value)"
/>
</div>
<!-- Desktop menu buttons -->
<div class="menu desktop-menu">
<button
mat-icon-button
class="add-playlist-btn"
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
[matTooltip]="'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate"
data-test-id="add-playlist"
>
<mat-icon>add</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<button
mat-icon-button
(click)="navigateToGlobalFavorites()"
[matTooltip]="'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate"
data-test-id="global-favorites"
>
<mat-icon>star</mat-icon>
</button>
<button
mat-icon-button
(click)="onGlobalSearchClick()"
[matTooltip]="
('PORTALS.SIDEBAR.SEARCH' | translate) + ' (Ctrl/Cmd+F)'
"
data-test-id="global-search"
>
<mat-icon>travel_explore</mat-icon>
</button>
<button
mat-icon-button
(click)="onGlobalRecentClick()"
[matTooltip]="
('PORTALS.SIDEBAR.RECENT' | translate) + ' (Ctrl/Cmd+R)'
"
data-test-id="global-recent"
>
<mat-icon>history</mat-icon>
</button>
<button
mat-icon-button
#filterMenuTrigger="matMenuTrigger"
[matMenuTriggerFor]="filterSortMenuComponent().menu()"
[matTooltip]="'HOME.FILTER_AND_SORT' | translate"
[matBadge]="filterSortMenuComponent().activeFiltersCount"
[matBadgeHidden]="
filterSortMenuComponent().activeFiltersCount === 0
"
matBadgeSize="small"
matBadgeColor="accent"
data-test-id="filter-sort-playlists"
>
<mat-icon>tune</mat-icon>
</button>
<app-filter-sort-menu [menuTrigger]="filterMenuTrigger" />
@if (isDesktop) {
<button
mat-icon-button
(click)="openSettings()"
[matTooltip]="'MENU.SETTINGS' | translate"
data-test-id="open-settings"
>
<mat-icon>settings</mat-icon>
</button>
} @else {
<button
mat-icon-button
[matMenuTriggerFor]="pwaMenu"
[attr.aria-label]="'MENU.OPEN' | translate"
data-test-id="pwa-menu"
>
<mat-icon>menu</mat-icon>
</button>
<mat-menu #pwaMenu="matMenu">
@if (isHome) {
<button
mat-menu-item
[attr.aria-label]="'MENU.SETTINGS_ARIA' | translate"
(click)="openSettings()"
data-test-id="pwa-open-settings"
>
<mat-icon>settings</mat-icon>
<span>{{ 'MENU.SETTINGS' | translate }}</span>
</button>
<mat-divider></mat-divider>
}
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_ARIA' | translate"
(click)="openUrl('https://github.com/4gray/iptvnator')"
>
<mat-icon>recommend</mat-icon>
<span>GitHub</span>
</button>
<button
mat-menu-item
[attr.aria-label]="'MENU.BUG_REPORT' | translate"
(click)="
openUrl('https://github.com/4gray/iptvnator/issues')
"
>
<mat-icon>bug_report</mat-icon>
<span>{{ 'MENU.BUG_REPORT' | translate }}</span>
</button>
<mat-divider />
<button
mat-menu-item
[attr.aria-label]="'MENU.SUPPORT_PROJECT' | translate"
(click)="openUrl('https://github.com/sponsors/4gray')"
>
<mat-icon>savings</mat-icon>
<span>{{ 'MENU.SUPPORT_PROJECT' | translate }}</span>
</button>
<mat-divider></mat-divider>
<button
mat-menu-item
[attr.aria-label]="'MENU.ABOUT_ARIA' | translate"
(click)="openAboutDialog()"
>
<mat-icon>info</mat-icon>
<span>{{ 'MENU.ABOUT' | translate }}</span>
</button>
</mat-menu>
}
</div>
<!-- Mobile three-dots menu (all buttons consolidated) -->
<div class="menu mobile-menu">
<button
mat-icon-button
[matMenuTriggerFor]="mobileActionsMenu"
[attr.aria-label]="'MENU.OPEN' | translate"
data-test-id="mobile-menu"
>
<mat-icon>more_vert</mat-icon>
</button>
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<app-filter-sort-menu [menuTrigger]="mobileFilterTrigger" />
<mat-menu #mobileActionsMenu="matMenu">
<button
mat-menu-item
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
data-test-id="mobile-add-playlist"
>
<mat-icon>add</mat-icon>
<span>{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}</span>
</button>
<button
mat-menu-item
(click)="navigateToGlobalFavorites()"
data-test-id="mobile-global-favorites"
>
<mat-icon>star</mat-icon>
<span>{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}</span>
</button>
<button
mat-menu-item
(click)="onGlobalSearchClick()"
data-test-id="mobile-global-search"
>
<mat-icon>travel_explore</mat-icon>
<span
>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}
(Ctrl/Cmd+F)</span
>
</button>
<button
mat-menu-item
(click)="onGlobalRecentClick()"
data-test-id="mobile-global-recent"
>
<mat-icon>history</mat-icon>
<span
>{{ 'PORTALS.SIDEBAR.RECENT' | translate }}
(Ctrl/Cmd+R)</span
>
</button>
<button
mat-menu-item
#mobileFilterTrigger="matMenuTrigger"
[matMenuTriggerFor]="filterSortMenuComponent().menu()"
data-test-id="mobile-filter-sort"
>
<mat-icon
[matBadge]="filterSortMenuComponent().activeFiltersCount"
[matBadgeHidden]="filterSortMenuComponent().activeFiltersCount === 0"
matBadgeSize="small"
matBadgeColor="accent"
>tune</mat-icon>
<span>{{ 'HOME.FILTER_AND_SORT' | translate }}</span>
</button>
<mat-divider />
<button
mat-menu-item
(click)="openSettings()"
data-test-id="mobile-settings"
>
<mat-icon>settings</mat-icon>
<span>{{ 'MENU.SETTINGS' | translate }}</span>
</button>
@if (!isDesktop) {
<mat-divider />
<button
mat-menu-item
(click)="openUrl('https://github.com/4gray/iptvnator')"
>
<mat-icon>recommend</mat-icon>
<span>GitHub</span>
</button>
<button
mat-menu-item
(click)="openUrl('https://github.com/4gray/iptvnator/issues')"
>
<mat-icon>bug_report</mat-icon>
<span>{{ 'MENU.BUG_REPORT' | translate }}</span>
</button>
<button
mat-menu-item
(click)="openUrl('https://github.com/sponsors/4gray')"
>
<mat-icon>savings</mat-icon>
<span>{{ 'MENU.SUPPORT_PROJECT' | translate }}</span>
</button>
<mat-divider />
<button
mat-menu-item
(click)="openAboutDialog()"
>
<mat-icon>info</mat-icon>
<span>{{ 'MENU.ABOUT' | translate }}</span>
</button>
}
</mat-menu>
</div>
}
</div>
@@ -1,556 +0,0 @@
:host {
display: block;
width: 100%;
}
.modern-header {
background: #131316;
color: #fff;
display: flex;
align-items: center;
width: 100%;
height: 70px;
padding: 0 16px;
app-region: drag;
position: relative;
overflow: hidden;
box-sizing: border-box;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
// Subtle depth layer
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.01) 0%,
rgba(255, 255, 255, 0) 100%
);
pointer-events: none;
}
}
// Refined home page styling - seamless with #131316
:host.home-header .modern-header {
background: #131316;
// Elegant ambient glow
&::before {
content: '';
position: absolute;
top: -100%;
left: 0;
width: 100%;
height: 200%;
background: radial-gradient(
ellipse at 50% 0%,
rgba(99, 102, 241, 0.04) 0%,
rgba(139, 92, 246, 0.02) 30%,
transparent 60%
);
animation: headerAmbience 20s ease-in-out infinite;
pointer-events: none;
}
// Refined bottom accent
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(139, 92, 246, 0.15) 50%,
transparent 100%
);
opacity: 0.5;
}
}
@keyframes headerAmbience {
0%,
100% {
opacity: 0.3;
transform: translateY(0);
}
50% {
opacity: 0.6;
transform: translateY(-5%);
}
}
// Refined brand block with elegant glass effect
.brand-block {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px 0 8px;
background: rgba(255, 255, 255, 0.02);
border-radius: 32px;
border: 1px solid rgba(255, 255, 255, 0.04);
backdrop-filter: blur(12px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
app-region: no-drag;
position: relative;
z-index: 1;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
&:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
}
// Enhanced brand block on home page - refined elegance
:host.home-header .brand-block {
background: rgba(139, 92, 246, 0.04);
border-color: rgba(139, 92, 246, 0.08);
box-shadow:
0 2px 12px rgba(139, 92, 246, 0.06),
inset 0 1px 0 rgba(139, 92, 246, 0.08);
&:hover {
background: rgba(139, 92, 246, 0.08);
border-color: rgba(139, 92, 246, 0.15);
box-shadow:
0 4px 16px rgba(139, 92, 246, 0.12),
inset 0 1px 0 rgba(139, 92, 246, 0.12);
}
}
.logo-pulse {
position: relative;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
img {
width: 38px;
height: 38px;
object-fit: contain;
position: relative;
z-index: 2;
filter: drop-shadow(0 2px 6px rgba(255, 59, 48, 0.25));
transition: filter 0.3s ease;
}
// Subtle ambient glow
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(
circle,
rgba(255, 59, 48, 0.15) 0%,
rgba(255, 59, 48, 0.05) 50%,
transparent 70%
);
border-radius: 50%;
animation: logoGlow 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.brand-block:hover & img {
filter: drop-shadow(0 2px 8px rgba(255, 59, 48, 0.4));
}
}
@keyframes logoGlow {
0%,
100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.15);
opacity: 1;
}
}
.brand-text {
display: flex;
flex-direction: column;
justify-content: center;
.title {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.015em;
color: rgba(255, 255, 255, 0.95);
transition: all 0.3s ease;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.brand-block:hover & .title {
color: rgba(255, 255, 255, 1);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
}
// Enhanced brand text on home page
:host.home-header .brand-text .title {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(139, 92, 246, 0.8) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
.brand-block:hover & {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 1) 0%,
rgba(139, 92, 246, 1) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
}
// Refined search field with elegant glass-morphism
.search-container {
flex: 1;
max-width: 800px;
margin: 0 24px;
position: relative;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 20px;
padding: 0 18px;
height: 46px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
app-region: no-drag;
z-index: 1;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.02);
&:focus-within {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(139, 92, 246, 0.3);
box-shadow:
0 0 0 2px rgba(139, 92, 246, 0.08),
0 2px 8px rgba(139, 92, 246, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
transform: translateY(-1px);
}
&:hover:not(:focus-within) {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
}
}
// Enhanced search field on home page - refined elegance
:host.home-header .search-container {
background: rgba(139, 92, 246, 0.03);
border-color: rgba(139, 92, 246, 0.08);
box-shadow:
0 1px 4px rgba(139, 92, 246, 0.04),
inset 0 1px 0 rgba(139, 92, 246, 0.04);
&:focus-within {
background: rgba(139, 92, 246, 0.06);
border-color: rgba(139, 92, 246, 0.3);
box-shadow:
0 0 0 2px rgba(139, 92, 246, 0.1),
0 4px 16px rgba(139, 92, 246, 0.15),
inset 0 1px 0 rgba(139, 92, 246, 0.08);
}
&:hover:not(:focus-within) {
background: rgba(139, 92, 246, 0.05);
border-color: rgba(139, 92, 246, 0.12);
}
}
:host.home-header .search-icon {
color: rgba(139, 92, 246, 0.5);
.search-container:focus-within & {
color: rgba(139, 92, 246, 0.85);
}
}
.search-icon {
color: rgba(255, 255, 255, 0.35);
margin-right: 12px;
font-size: 20px;
width: 20px;
height: 20px;
transition: color 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.search-container:focus-within & {
color: rgba(139, 92, 246, 0.85);
}
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: rgba(255, 255, 255, 0.95);
font-size: 14.5px;
font-weight: 400;
letter-spacing: -0.005em;
&::placeholder {
color: rgba(255, 255, 255, 0.25);
font-weight: 400;
letter-spacing: 0;
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
height: 16px;
width: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='rgba(255,255,255,0.35)'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
cursor: pointer;
opacity: 0.5;
transition: all 0.3s ease;
&:hover {
opacity: 1;
transform: scale(1.1);
}
}
}
.menu {
display: flex;
align-items: center;
gap: 4px;
app-region: no-drag; // Allow button interactions
}
.add-playlist-btn {
background-color: rgba(139, 92, 246, 0.12) !important;
color: rgba(139, 92, 246, 1) !important;
border: 1px solid rgba(139, 92, 246, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(139, 92, 246, 0.2) !important;
border-color: rgba(139, 92, 246, 0.3);
box-shadow: 0 2px 8px rgba(139, 92, 246, 0.15);
}
.mat-icon {
color: rgba(139, 92, 246, 1) !important;
}
}
// Badge styling for filter count
::ng-deep .mat-badge-accent .mat-badge-content {
background: rgba(139, 92, 246, 1);
color: white;
}
// Refined Material button and icon overrides
.modern-header {
// Elegant icon styling
::ng-deep .mat-icon {
color: rgba(255, 255, 255, 0.85) !important;
transition: color 0.3s ease;
}
// Refined icon buttons with glass effect
::ng-deep .mat-mdc-icon-button {
color: rgba(255, 255, 255, 0.85) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background-color: rgba(255, 255, 255, 0.06) !important;
.mat-icon {
color: rgba(255, 255, 255, 1) !important;
}
}
&:active {
background-color: rgba(255, 255, 255, 0.1) !important;
transform: scale(0.96);
}
}
// Menu items
::ng-deep .mat-mdc-menu-item {
color: rgba(255, 255, 255, 0.9) !important;
.mat-icon {
color: rgba(255, 255, 255, 0.8) !important;
}
span {
color: rgba(255, 255, 255, 0.9) !important;
}
&:hover {
.mat-icon {
color: rgba(255, 255, 255, 1) !important;
}
}
}
// Checkbox styling
::ng-deep .mat-mdc-checkbox {
color: rgba(255, 255, 255, 0.9) !important;
.mdc-checkbox__background {
border-color: rgba(255, 255, 255, 0.6) !important;
}
}
}
// Home page - refined purple accent for icons
:host.home-header .modern-header {
::ng-deep .mat-icon {
color: rgba(255, 255, 255, 0.85) !important;
}
::ng-deep .mat-mdc-icon-button {
&:hover {
background-color: rgba(139, 92, 246, 0.1) !important;
.mat-icon {
color: rgba(139, 92, 246, 1) !important;
}
}
&:active {
background-color: rgba(139, 92, 246, 0.15) !important;
}
}
}
// Mobile menu visibility
.mobile-menu {
display: none;
}
.desktop-menu {
display: flex;
}
// Responsive design
@media (max-width: 899px) {
.search-container {
max-width: 300px;
margin: 0 16px;
}
}
@media (max-width: 768px) {
// Hide desktop menu, show mobile menu
.desktop-menu {
display: none;
}
.mobile-menu {
display: flex;
}
// Hide brand text, keep only icon
.brand-text {
display: none;
}
.brand-block {
padding: 6px;
border-radius: 50%;
gap: 0;
}
// Smaller search bar
.search-container {
flex: 1;
max-width: none;
margin: 0 12px;
height: 40px;
padding: 0 14px;
border-radius: 16px;
.search-input {
font-size: 13px;
&::placeholder {
font-size: 13px;
}
}
.search-icon {
font-size: 18px;
width: 18px;
height: 18px;
margin-right: 10px;
}
}
}
@media (max-width: 599px) {
.modern-header {
height: 56px;
padding: 0 12px;
}
.brand-block {
padding: 4px;
}
.logo-pulse {
width: 36px;
height: 36px;
img {
width: 28px;
height: 28px;
}
}
.search-container {
margin: 0 8px;
height: 36px;
padding: 0 12px;
border-radius: 14px;
.search-input {
font-size: 12px;
&::placeholder {
font-size: 12px;
}
}
.search-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 8px;
}
}
.menu {
gap: 0;
}
}
@@ -1,129 +0,0 @@
import {
Component,
OnInit,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from 'services';
//import { shell } from 'electron';
import { AddPlaylistMenuComponent, PlaylistType } from 'components';
import { AboutDialogComponent } from '../about-dialog/about-dialog.component';
import { AddPlaylistDialogComponent } from '../add-playlist/add-playlist-dialog.component';
import { FilterSortMenuComponent } from '../filter-sort-menu/filter-sort-menu.component';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
imports: [
AddPlaylistMenuComponent,
FilterSortMenuComponent,
FormsModule,
MatBadgeModule,
MatButtonModule,
MatDividerModule,
MatIconModule,
MatMenuModule,
MatTooltipModule,
ReactiveFormsModule,
TranslateModule,
],
})
export class HeaderComponent implements OnInit {
private activatedRoute = inject(ActivatedRoute);
private dialog = inject(MatDialog);
private dataService = inject(DataService);
private router = inject(Router);
readonly addPlaylistMenuComponent = viewChild.required(
AddPlaylistMenuComponent
);
readonly filterSortMenuComponent = viewChild.required(
FilterSortMenuComponent
);
readonly isDesktop = !!window.electron;
readonly title = input.required<string>();
readonly subtitle = input.required<string>();
readonly searchQuery = output<string>();
readonly globalSearchClicked = output<void>();
readonly globalRecentClicked = output<void>();
readonly isHome = false;
ngOnInit() {
// Initialization if needed
}
/**
* Navigates to the settings page
*/
openSettings(): void {
this.router.navigate(['/settings']);
}
/**
* Navigates to the global favorites view
*/
navigateToGlobalFavorites(): void {
this.router.navigate(['/workspace', 'global-favorites']);
}
/**
* Opens the provided URL string in new browser window
* @param url url to open
*/
async openUrl(url: string): Promise<void> {
if (this.isDesktop) {
console.log('TODO: implement me');
// await shell.openExternal(url);
} else {
window.open(url, '_blank');
}
}
/**
* Opens the about dialog with description and version of
* the app
*/
openAboutDialog(): void {
this.dialog.open(AboutDialogComponent, {
panelClass: 'about-dialog-overlay',
width: '600px',
data: this.dataService.getAppVersion(),
});
}
openAddPlaylistDialog(type: PlaylistType) {
this.dialog.open<AddPlaylistDialogComponent, { type: PlaylistType }>(
AddPlaylistDialogComponent,
{
width: '600px',
data: { type },
}
);
}
onSearchQueryUpdate(query: string): void {
this.searchQuery.emit(query);
}
onGlobalSearchClick(): void {
this.globalSearchClicked.emit();
}
onGlobalRecentClick(): void {
this.globalRecentClicked.emit();
}
}
@@ -1,12 +0,0 @@
export {
CategoryViewComponent,
ContentCardComponent,
FavoritesLayoutComponent,
PlaylistErrorViewComponent,
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
PortalEmptyStateComponent,
SearchFormComponent,
SearchLayoutComponent,
} from '@iptvnator/portal/shared/ui';
export * from './header/header.component';
@@ -1,39 +0,0 @@
<!-- @if ((activeProcesses$ | async)?.length) {
<div class="mpv-player-bar">
@for (process of activeProcesses$ | async; track process.id) {
<div class="player-item">
<div class="player-media">
@if (process.thumbnail) {
<img
[src]="process.thumbnail"
[alt]="process.title"
class="media-thumbnail"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
/>
} @else {
<div class="media-thumbnail-placeholder">
<mat-icon>live_tv</mat-icon>
</div>
}
<div class="media-info">
<span class="media-title">{{ process.title }}</span>
<span class="media-url">{{ process.url }}</span>
</div>
</div>
<div class="player-controls">
<button
mat-icon-button
(click)="closeStream(process.id)"
title="Close"
>
<mat-icon>close</mat-icon>
</button>
</div>
</div>
}
</div>
}
-->
@@ -1,103 +0,0 @@
.mpv-player-bar {
background: #282828;
color: white;
padding: 16px;
box-shadow: 0 -2px 20px rgba(0, 0, 0, 0.3);
flex-shrink: 0; // Prevent shrinking
}
.player-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.player-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
height: 56px;
}
.player-info {
display: flex;
flex-direction: column;
}
.player-title {
font-size: 14px;
font-weight: 500;
}
.player-url {
font-size: 12px;
color: #a0a0a0;
margin-top: 4px;
}
.player-media {
display: flex;
align-items: center;
gap: 16px;
flex: 1;
}
.media-thumbnail {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 4px;
}
.media-thumbnail-placeholder {
width: 56px;
height: 56px;
background: #404040;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 24px;
color: #b3b3b3;
}
}
.media-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.media-title {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-url {
font-size: 12px;
color: #b3b3b3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-controls {
display: flex;
align-items: center;
gap: 8px;
button {
color: #fff;
&:hover {
color: #1db954;
}
}
}
@@ -1,24 +0,0 @@
import { Component } from '@angular/core';
@Component({
/* imports: [MatIcon, MatIconButton], */
selector: 'app-mpv-player-bar',
templateUrl: './mpv-player-bar.component.html',
styleUrls: ['./mpv-player-bar.component.scss'],
})
export class MpvPlayerBarComponent {
/* private mpvPlayerService = inject(MpvPlayerService);
activeProcesses$ = this.mpvPlayerService.activeProcesses$;
async closeStream(processId: number): Promise<void> {
await this.mpvPlayerService.closeStream(processId);
} */
/* async playStream(processId: number): Promise<void> {
await this.mpvPlayerService.playStream(processId);
}
async pauseStream(processId: number): Promise<void> {
await this.mpvPlayerService.pauseStream(processId);
} */
}
@@ -1,78 +0,0 @@
/* import { Injectable } from '@angular/core';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { BehaviorSubject } from 'rxjs';
export interface MpvProcess {
id: number;
url: string;
start_time: number;
last_known_time: number | null;
thumbnail?: string;
title: string;
}
@Injectable({
providedIn: 'root',
})
export class MpvPlayerService {
private activeProcessesSubject = new BehaviorSubject<MpvProcess[]>([]);
public activeProcesses$ = this.activeProcessesSubject.asObservable();
constructor() {
if (!window.electron) {
return;
}
this.initializeEventListeners();
this.loadActiveProcesses();
}
private async initializeEventListeners() {
// Listen for new processes
await listen('mpv-process-added', (event: any) => {
const newProcess = event.payload as MpvProcess;
if (newProcess) {
const currentProcesses = this.activeProcessesSubject.value;
this.activeProcessesSubject.next([
...currentProcesses,
newProcess,
]);
}
});
// Listen for removed processes
await listen('mpv-process-removed', (event: any) => {
const removedProcess = event.payload as MpvProcess;
if (removedProcess) {
const currentProcesses = this.activeProcessesSubject.value;
this.activeProcessesSubject.next(
currentProcesses.filter((p) => p.id !== removedProcess.id)
);
}
});
}
private async loadActiveProcesses() {
try {
const processes = await invoke<MpvProcess[]>(
'get_active_mpv_processes'
);
this.activeProcessesSubject.next(processes);
} catch (error) {
console.error('Failed to load active MPV processes:', error);
}
}
async playStream(processId: number): Promise<void> {
await invoke('mpv_play', { processId });
}
async pauseStream(processId: number): Promise<void> {
await invoke('mpv_pause', { processId });
}
async closeStream(processId: number): Promise<void> {
await invoke('close_mpv_process', { processId });
}
}
*/
@@ -1 +0,0 @@
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/content-grid';
@@ -1 +0,0 @@
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-layout';
@@ -1 +0,0 @@
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-sidebar';
@@ -1,27 +0,0 @@
import { StalkerFeatureRouteOptions } from '@iptvnator/portal/stalker/feature';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
// Temporary app-side loader map while Stalker route screens remain in apps/web.
export const stalkerFeatureRouteOptions: StalkerFeatureRouteOptions = {
catalogProviders: provideStalkerCatalogFacade(),
loadCategoryContentViewComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
loadLiveStreamLayoutComponent: () =>
import(
'./stalker-live-stream-layout/stalker-live-stream-layout.component'
).then((c) => c.StalkerLiveStreamLayoutComponent),
loadFavoritesComponent: () =>
import('./stalker-favorites/stalker-favorites.component').then(
(c) => c.StalkerFavoritesComponent
),
loadRecentlyViewedComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
loadSearchComponent: () =>
import('./stalker-search/stalker-search.component').then(
(c) => c.StalkerSearchComponent
),
};
@@ -1,161 +0,0 @@
<section class="dashboard-page">
<!-- ─── Hero ─────────────────────────────────────────────────────────── -->
<header class="hero">
<div class="hero-content">
<div class="hero-text">
<p class="eyebrow">
<span class="eyebrow-dot"></span>
Workspace
</p>
<h1>Your IPTV sources,<br />one place.</h1>
<p class="description">
Jump back into your active source, switch providers
instantly, or explore what's new across your library.
</p>
<div class="hero-actions">
@if (activePlaylist(); as active) {
<a
mat-flat-button
[routerLink]="getPlaylistLink(active)"
>
<mat-icon>play_arrow</mat-icon>
Continue with {{ active.title }}
</a>
} @else {
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>library_books</mat-icon>
Open Sources
</a>
}
<a mat-stroked-button routerLink="/settings">
<mat-icon>settings</mat-icon>
Settings
</a>
</div>
</div>
<div class="hero-visual" aria-hidden="true">
<div class="hero-icon-ring">
<mat-icon>tv</mat-icon>
</div>
</div>
</div>
</header>
<!-- ─── Stats Row ─────────────────────────────────────────────────────── -->
<div class="stat-grid">
<article class="stat-card stat-card--total">
<div class="stat-card__icon">
<mat-icon>folder_open</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().total }}</span>
<span class="stat-card__label">Total Sources</span>
</div>
</article>
<article class="stat-card stat-card--xtream">
<div class="stat-card__icon">
<mat-icon>stream</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().xtream }}</span>
<span class="stat-card__label">Xtream</span>
</div>
</article>
<article class="stat-card stat-card--stalker">
<div class="stat-card__icon">
<mat-icon>router</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().stalker }}</span>
<span class="stat-card__label">Stalker</span>
</div>
</article>
<article class="stat-card stat-card--m3u">
<div class="stat-card__icon">
<mat-icon>playlist_play</mat-icon>
</div>
<div class="stat-card__body">
<span class="stat-card__value">{{ stats().m3u }}</span>
<span class="stat-card__label">M3U</span>
</div>
</article>
</div>
<!-- ─── Recent Sources ────────────────────────────────────────────────── -->
<section class="sources-section">
<div class="section-header">
<h2>Recent Sources</h2>
<a routerLink="/workspace/sources" class="section-link">
<mat-icon>arrow_forward</mat-icon>
View all
</a>
</div>
@if (recentPlaylists().length > 0) {
<div class="sources-grid">
@for (
playlist of recentPlaylists();
track trackByPlaylistId($index, playlist)
) {
<a
class="source-card"
[routerLink]="getPlaylistLink(playlist)"
>
<div class="source-card__header">
<span
class="provider-badge"
[class]="
'provider-badge--' +
getPlaylistProvider(playlist).toLowerCase()
"
>
{{ getPlaylistProvider(playlist) }}
</span>
@if (playlist.count) {
<span class="source-card__count">
{{ playlist.count | number }} ch
</span>
}
</div>
<h3
[attr.title]="
playlist.title ||
playlist.filename ||
'Untitled source'
"
>
{{
playlist.title ||
playlist.filename ||
'Untitled source'
}}
</h3>
<p class="source-card__date">
<mat-icon>schedule</mat-icon>
{{
formatTimestamp(
playlist.updateDate || playlist.importDate
)
}}
</p>
</a>
}
</div>
} @else {
<article class="empty-state">
<div class="empty-state__icon">
<mat-icon>library_add</mat-icon>
</div>
<h3>No sources yet</h3>
<p>Add a playlist in Library to start using the workspace.</p>
<a mat-flat-button routerLink="/workspace/sources">
<mat-icon>add</mat-icon>
Add Source
</a>
</article>
}
</section>
</section>
@@ -1,439 +0,0 @@
:host {
display: block;
min-height: 100%;
padding: 20px;
box-sizing: border-box;
background: var(--app-content-bg, var(--mat-sys-surface));
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.dashboard-page {
max-width: 1280px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
// ─── Hero ─────────────────────────────────────────────────────────────────────
.hero {
border-radius: 16px;
padding: 28px;
background: var(--app-widget-bg, var(--mat-sys-surface-container-high));
background-image: var(--app-accent-gradient);
border: none;
position: relative;
overflow: hidden;
}
.hero-content {
display: flex;
align-items: center;
gap: 24px;
}
.hero-text {
flex: 1;
}
.eyebrow {
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 7px;
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.72rem;
font-weight: 600;
color: var(--app-eyebrow-color, var(--mat-sys-on-surface-variant));
}
.eyebrow-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mat-sys-primary);
flex-shrink: 0;
}
h1 {
margin: 0 0 12px;
font-size: clamp(1.5rem, 2.2vw, 2rem);
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.description {
margin: 0;
max-width: 560px;
line-height: 1.55;
font-size: 0.9rem;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
}
.hero-actions {
margin-top: 20px;
display: flex;
flex-wrap: wrap;
gap: 10px;
a {
text-decoration: none;
}
}
// Visual accent element on the right
.hero-visual {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.hero-icon-ring {
width: 80px;
height: 80px;
border-radius: 24px;
background: var(--mat-sys-primary-container);
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--mat-sys-outline-variant);
mat-icon {
font-size: 36px;
width: 36px;
height: 36px;
color: var(--mat-sys-on-primary-container);
}
}
// ─── Stat Grid ────────────────────────────────────────────────────────────────
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.stat-card {
border-radius: 14px;
padding: 16px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: none;
box-shadow: none;
display: flex;
align-items: center;
gap: 14px;
transition:
transform 0.15s ease,
background-color 0.15s ease;
overflow: hidden;
position: relative;
// Top accent line per type
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
}
&:hover {
transform: translateY(-2px);
background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high));
}
}
.stat-card--total::before {
background: var(--mat-sys-primary);
}
.stat-card--xtream::before {
background: var(--app-provider-xtream, #818cf8);
}
.stat-card--stalker::before {
background: var(--app-provider-stalker, #34d399);
}
.stat-card--m3u::before {
background: var(--app-provider-m3u, #fbbf24);
}
.stat-card__icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface-container-high);
flex-shrink: 0;
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--mat-sys-on-surface-variant);
}
}
.stat-card--xtream .stat-card__icon mat-icon {
color: var(--app-provider-xtream, #818cf8);
}
.stat-card--stalker .stat-card__icon mat-icon {
color: var(--app-provider-stalker, #34d399);
}
.stat-card--m3u .stat-card__icon mat-icon {
color: var(--app-provider-m3u, #fbbf24);
}
.stat-card__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat-card__value {
font-size: 1.6rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
.stat-card__label {
font-size: 0.8rem;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
font-weight: 500;
}
// ─── Sources Section ──────────────────────────────────────────────────────────
.sources-section {
border-radius: 14px;
padding: 18px;
background: var(--app-widget-bg, var(--mat-sys-surface-container));
border: none;
box-shadow: none;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--app-heading-color, var(--mat-sys-on-surface));
}
}
.section-link {
display: flex;
align-items: center;
gap: 4px;
color: var(--mat-sys-primary);
text-decoration: none;
font-size: 0.82rem;
font-weight: 500;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.75;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
}
}
// ─── Source Cards ─────────────────────────────────────────────────────────────
.sources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.source-card {
border-radius: 12px;
padding: 14px;
border: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface-container-low);
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
overflow: hidden;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
&:hover {
border-color: var(--mat-sys-primary);
background: var(--app-card-hover-bg, var(--mat-sys-surface-container-high));
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
.source-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
// Provider Badges
.provider-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
&--xtream {
background: rgba(129, 140, 248, 0.14);
color: var(--app-provider-xtream, #818cf8);
}
&--stalker {
background: rgba(52, 211, 153, 0.12);
color: var(--app-provider-stalker, #34d399);
}
&--m3u {
background: rgba(251, 191, 36, 0.12);
color: var(--app-provider-m3u, #fbbf24);
}
}
.source-card__count {
font-size: 0.72rem;
color: var(--mat-sys-on-surface-variant);
}
.source-card h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-card__date {
margin: 0;
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
mat-icon {
font-size: 13px;
width: 13px;
height: 13px;
flex-shrink: 0;
}
}
// ─── Empty State ──────────────────────────────────────────────────────────────
.empty-state {
border-radius: 14px;
border: 1px dashed var(--mat-sys-outline);
padding: 32px 24px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background: var(--mat-sys-surface-container-low);
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
p {
margin: 0 0 6px;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
max-width: 340px;
line-height: 1.5;
}
a {
text-decoration: none;
}
}
.empty-state__icon {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--mat-sys-surface-container-high);
display: flex;
align-items: center;
justify-content: center;
mat-icon {
font-size: 28px;
width: 28px;
height: 28px;
color: var(--mat-sys-on-surface-variant);
}
}
// ─── Responsive ───────────────────────────────────────────────────────────────
@media (max-width: 1024px) {
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 780px) {
.hero-visual {
display: none;
}
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sources-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
:host {
padding: 12px;
}
.hero {
padding: 20px;
}
.stat-grid {
grid-template-columns: 1fr 1fr;
}
}
@@ -1,98 +0,0 @@
import { DecimalPipe } from '@angular/common';
import { Component, computed, inject } from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { selectActivePlaylist, selectAllPlaylistsMeta } from 'm3u-state';
import { PlaylistMeta } from 'shared-interfaces';
@Component({
selector: 'app-dashboard-placeholder',
imports: [DecimalPipe, MatButton, MatIcon, RouterLink],
templateUrl: './dashboard-placeholder.component.html',
styleUrl: './dashboard-placeholder.component.scss',
})
export class DashboardPlaceholderComponent {
private readonly store = inject(Store);
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly stats = computed(() => {
const items = this.playlists();
return {
total: items.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
};
});
readonly recentPlaylists = computed(() =>
[...this.playlists()]
.sort(
(a, b) =>
this.getRecentTimestamp(b) - this.getRecentTimestamp(a)
)
.slice(0, 8)
);
getPlaylistLink(playlist: PlaylistMeta): string[] {
if (playlist.serverUrl) {
return ['/workspace', 'xtreams', playlist._id, 'vod'];
}
if (playlist.macAddress) {
return ['/workspace', 'stalker', playlist._id, 'vod'];
}
return ['/workspace', 'playlists', playlist._id];
}
getPlaylistProvider(playlist: PlaylistMeta): 'Xtream' | 'Stalker' | 'M3U' {
if (playlist.serverUrl) {
return 'Xtream';
}
if (playlist.macAddress) {
return 'Stalker';
}
return 'M3U';
}
trackByPlaylistId(_: number, item: PlaylistMeta): string {
return item._id;
}
formatTimestamp(value?: string | number): string {
const timestamp = this.toTimestamp(value);
if (!timestamp) {
return 'Not yet synced';
}
return new Date(timestamp).toLocaleString();
}
private getRecentTimestamp(item: PlaylistMeta): number {
return (
this.toTimestamp(item.updateDate) ||
this.toTimestamp(item.importDate)
);
}
private toTimestamp(value?: string | number): number {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
}
@@ -1 +0,0 @@
export { FavoritesContextService } from '@iptvnator/portal/shared/util';
@@ -1,40 +0,0 @@
import { XtreamFeatureRouteOptions } from '@iptvnator/portal/xtream/feature';
import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service';
// Temporary app-side loader map while route screens are still being migrated
// out of apps/web into the Xtream feature library.
export const xtreamFeatureRouteOptions: XtreamFeatureRouteOptions = {
catalogProviders: provideXtreamCatalogFacade(),
loadLiveStreamLayoutComponent: () =>
import('./live-stream-layout/live-stream-layout.component').then(
(c) => c.LiveStreamLayoutComponent
),
loadCategoryContentViewComponent: () =>
import(
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
loadVodDetailsRouteComponent: () =>
import('./vod-details/vod-details-route.component').then(
(c) => c.VodDetailsRouteComponent
),
loadSerialDetailsComponent: () =>
import('./serial-details/serial-details.component').then(
(c) => c.SerialDetailsComponent
),
loadFavoritesComponent: () =>
import('./favorites/favorites.component').then(
(c) => c.FavoritesComponent
),
loadRecentlyViewedComponent: () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
),
loadSearchResultsComponent: () =>
import('./search-results/search-results.component').then(
(c) => c.SearchResultsComponent
),
loadRecentlyAddedComponent: () =>
import('./recently-added/recently-added.component').then(
(c) => c.RecentlyAddedComponent
),
};
-16
View File
@@ -15,11 +15,6 @@
// ─── Shared Components ────────────────────────────────────────────────────────
// ─── macOS specific: space for traffic lights ─────────────────────────────────
app-root.macos-platform app-header .modern-header {
padding-left: 80px;
}
app-root.macos-platform .current-playlist {
padding-top: 30px;
}
@@ -93,16 +88,6 @@ textarea,
}
}
// ─── Dialogs ──────────────────────────────────────────────────────────────────
.about-dialog-overlay {
.mat-mdc-dialog-surface {
text-align: center;
background: var(--mat-sys-surface) !important;
color: var(--mat-sys-on-surface) !important;
padding: 10px;
}
}
.scroll-viewport-portals {
min-height: calc(100vh - 170px) !important;
overflow-x: hidden !important;
@@ -122,4 +107,3 @@ textarea,
.global-search-overlay .mat-mdc-dialog-container {
app-region: no-drag;
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "playlist-import-feature",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/import/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1 @@
export * from './lib/add-playlist-dialog/add-playlist-dialog.component';
@@ -8,17 +8,16 @@ import {
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistType } from 'components';
import { PlaylistActions } from 'm3u-state';
import { getFilenameFromUrl } from 'm3u-utils';
import { DataService } from 'services';
import { PLAYLIST_PARSE_BY_URL } from 'shared-interfaces';
import { FileUploadComponent } from '../../../home/file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../../../home/stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../../../home/text-import/text-import.component';
import { UrlUploadComponent } from '../../../home/url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../../../home/xtream-code-import/xtream-code-import.component';
export type PlaylistType = 'xtream' | 'url' | 'text' | 'file' | 'stalker';
import { FileUploadComponent } from '../file-upload/file-upload.component';
import { StalkerPortalImportComponent } from '../stalker-portal-import/stalker-portal-import.component';
import { TextImportComponent } from '../text-import/text-import.component';
import { UrlUploadComponent } from '../url-upload/url-upload.component';
import { XtreamCodeImportComponent } from '../xtream-code-import/xtream-code-import.component';
@Component({
imports: [
@@ -102,7 +101,7 @@ export class AddPlaylistDialogComponent {
this.closeDialog();
}
closeDialog() {
closeDialog(): void {
this.dialogRef.close();
}
}
@@ -10,28 +10,30 @@ import {
selector: '[appDragDropFileUpload]',
})
export class DragDropFileUploadDirective {
defaultColor = 'rgb(255 255 255 / 15%)';
@Output() fileDropped = new EventEmitter<any>();
readonly defaultColor = 'rgb(255 255 255 / 15%)';
@Output() fileDropped = new EventEmitter<FileList>();
@HostBinding('style.background-color') background = this.defaultColor;
@HostListener('dragover', ['$event']) dragOver(event: any) {
@HostListener('dragover', ['$event']) dragOver(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.background = 'rgb(255 255 255 / 25%)';
}
@HostListener('dragleave', ['$event']) public dragLeave(event: any) {
@HostListener('dragleave', ['$event'])
dragLeave(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.background = this.defaultColor;
}
@HostListener('drop', ['$event']) public drop(event: any) {
@HostListener('drop', ['$event'])
drop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.background = this.defaultColor;
const files = event.dataTransfer.files;
if (files.length > 0) {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
this.fileDropped.emit(files);
}
}
@@ -6,8 +6,6 @@ import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { MockModule } from 'ng-mocks';
import { DataService } from '../../../../../../libs/services/src/lib/data.service';
import { ElectronServiceStub } from '../../services/electron.service.stub';
import { UrlUploadComponent } from './url-upload.component';
describe('UrlUploadComponent', () => {
@@ -25,12 +23,6 @@ describe('UrlUploadComponent', () => {
TranslateModule.forRoot(),
NoopAnimationsModule,
],
providers: [
{
provide: DataService,
useClass: ElectronServiceStub,
},
],
}).compileComponents();
}));
@@ -0,0 +1,13 @@
{
"name": "playlist-m3u-feature-player",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playlist/m3u/feature-player/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:playlist", "domain:m3u", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
@@ -0,0 +1,2 @@
export * from './lib/playlist-player-actions';
export * from './lib/video-player/video-player.component';
@@ -0,0 +1,8 @@
import { InjectionToken } from '@angular/core';
export interface PlaylistPlayerActions {
openSettings(): void;
}
export const PLAYLIST_PLAYER_ACTIONS =
new InjectionToken<PlaylistPlayerActions>('PLAYLIST_PLAYER_ACTIONS');
@@ -12,7 +12,6 @@ import {
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatSidenavModule } from '@angular/material/sidenav';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -52,7 +51,11 @@ import {
switchMap,
take,
} from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '@iptvnator/portal/shared/util';
import { DataService, PlaylistsService, SettingsStore } from 'services';
import {
Channel,
PLAYLIST_PARSE_BY_URL,
@@ -61,12 +64,7 @@ import {
SidebarView,
VideoPlayer,
} from 'shared-interfaces';
import { SettingsStore } from '../../services/settings-store.service';
import { SettingsComponent } from '../../settings/settings.component';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
import { PLAYLIST_PLAYER_ACTIONS } from '../playlist-player-actions';
@Component({
selector: 'app-video-player',
@@ -93,8 +91,8 @@ import {
export class VideoPlayerComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly dataService = inject(DataService);
private readonly dialog = inject(MatDialog);
private readonly overlay = inject(Overlay);
private readonly playerActions = inject(PLAYLIST_PLAYER_ACTIONS);
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly settingsStore = inject(SettingsStore);
@@ -210,7 +208,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
.pipe(
distinctUntilChanged((prev, curr) => prev['id'] === curr['id']),
combineLatestWith(this.activatedRoute.queryParams),
switchMap(([params, queryParams]) => {
switchMap(([params]) => {
const isGlobalFavorites = this.router.url.includes(
'/workspace/global-favorites'
);
@@ -464,12 +462,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
openSettings() {
this.dialog.open(SettingsComponent, {
width: '1200px',
maxWidth: '96vw',
maxHeight: '92vh',
data: { isDialog: true },
});
this.playerActions.openSettings();
}
/**
+13
View File
@@ -0,0 +1,13 @@
{
"name": "portal-catalog-feature",
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/catalog/feature/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "type:feature"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/category-content-view/category-content-view.component';
@@ -15,16 +15,18 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
StalkerSeriesViewComponent,
VodDetailsComponent,
} from 'components';
import {
GridListComponent,
PlaylistErrorViewComponent,
} from '@iptvnator/portal/shared/ui';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
isStalkerPortalCatalogFacade,
PortalCatalogFacade,
@@ -41,13 +43,14 @@ import {
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService } from '../../services/downloads.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { PlayerService } from '../../services/player.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import { VodDetailsComponent } from '../vod-details/vod-details.component';
import { PlaylistsService, DownloadsService } from 'services';
import {
createStalkerVodItem,
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
} from 'shared-interfaces';
interface CategoryContentItem {
id?: number | string;
@@ -89,16 +92,14 @@ interface DownloadVodData {
export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
private readonly playerService = inject(PlayerService);
readonly externalPlayback = inject(ExternalPlaybackService);
private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playlistService = inject(PlaylistsService);
private readonly downloadsService = inject(DownloadsService);
private readonly catalog = inject(
PORTAL_CATALOG_FACADE
) as PortalCatalogFacade<
private readonly catalog = inject(PORTAL_CATALOG_FACADE) as PortalCatalogFacade<
CategoryContentItem,
CategoryContentItem,
CategoryContentItem
@@ -194,7 +195,8 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
}
effect(() => {
this.selectedItem()?.id;
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
this.closeInlinePlayer();
});
}
@@ -325,7 +327,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
*/
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions =
await this.dataSource.getAllPlaybackPositions(playlistId);
await this.playbackPositions.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
@@ -352,7 +354,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
playlistId: string,
vodId: number
): Promise<void> {
const position = await this.dataSource.getPlaybackPosition(
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
@@ -494,7 +496,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
durationSeconds: Math.floor(event.duration),
};
void this.dataSource.savePlaybackPosition(
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
@@ -597,13 +599,13 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
);
this.lastInlineSaveTime = 0;
if (this.playerService.isEmbeddedPlayer()) {
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.playerService.openResolvedPlayback(playback, true);
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
+3 -2
View File
@@ -1,13 +1,14 @@
export * from './lib/components/category-view/category-view.component';
export * from './lib/components/content-card/content-card.component';
export * from './lib/components/favorites-layout/favorites-layout.component';
export * from './lib/components/grid-list/grid-list.component';
export * from './lib/components/playlist-error-view/playlist-error-view.component';
export * from './lib/components/progress-capsule/progress-capsule.component';
export { ProgressCapsuleComponent } from 'components';
export * from './lib/components/portal-collection-live-shell/portal-collection-live-shell.component';
export * from './lib/components/portal-collection-shell/portal-collection-shell.component';
export * from './lib/components/portal-empty-state/portal-empty-state.component';
export * from './lib/components/search-form/search-form.component';
export * from './lib/components/search-layout/search-layout.component';
export * from './lib/components/watched-badge/watched-badge.component';
export { WatchedBadgeComponent } from 'components';
export * from './lib/navigation/navigation.component';
export * from './lib/navigation/portal-rail-links.component';
@@ -73,5 +73,4 @@
</div>
}
</div>
<!-- <app-mpv-player-bar /> -->
</div>
@@ -5,10 +5,10 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistErrorViewComponent,
ProgressCapsuleComponent,
WatchedBadgeComponent,
} from '@iptvnator/portal/shared/ui';
} from 'components';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
interface GridListItem {
id?: number | string;
+3
View File
@@ -1,9 +1,12 @@
export * from './lib/favorites-context.service';
export * from './lib/logger';
export * from './lib/portal-catalog-facade';
export * from './lib/portal-external-playback';
export * from './lib/portal-player';
export * from './lib/portal-playback-positions';
export * from './lib/portal-navigation-actions';
export * from './lib/portal-collection-context';
export * from './lib/portal-collection-items';
export * from './lib/remote-channel-navigation';
export * from './lib/navigation/portal-rail-links';
export * from './lib/navigation/portal-route.utils';
@@ -0,0 +1,14 @@
import { InjectionToken, Signal } from '@angular/core';
import { ExternalPlayerSession } from 'shared-interfaces';
export interface PortalExternalPlayback {
readonly activeSession: Signal<ExternalPlayerSession | null>;
readonly visibleSession: Signal<ExternalPlayerSession | null>;
dismissActiveSession(): void;
closeSession(
session: ExternalPlayerSession | null | undefined
): Promise<void>;
}
export const PORTAL_EXTERNAL_PLAYBACK =
new InjectionToken<PortalExternalPlayback>('PORTAL_EXTERNAL_PLAYBACK');
@@ -0,0 +1,60 @@
import { InjectionToken } from '@angular/core';
import { PlaybackPositionData } from 'shared-interfaces';
export interface PortalPlaybackPositions {
savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null>;
getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]>;
getAllPlaybackPositions(playlistId: string): Promise<PlaybackPositionData[]>;
clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
}
export const PORTAL_PLAYBACK_POSITIONS =
new InjectionToken<PortalPlaybackPositions>('PORTAL_PLAYBACK_POSITIONS');
export function getPortalPlaybackProgressPercent(
position: PlaybackPositionData | null | undefined
): number {
if (!position || !position.durationSeconds) {
return 0;
}
const percent = (position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
export function isPortalPlaybackWatched(
position: PlaybackPositionData | null | undefined
): boolean {
return getPortalPlaybackProgressPercent(position) >= 90;
}
export function isPortalPlaybackInProgress(
position: PlaybackPositionData | null | undefined
): boolean {
if (!position) {
return false;
}
const percent = getPortalPlaybackProgressPercent(position);
return position.positionSeconds > 10 && percent < 90;
}
@@ -5,6 +5,7 @@ import {
} from 'shared-interfaces';
export interface PortalPlayer {
isEmbeddedPlayer(): boolean;
openPlayer(
streamUrl: string,
title: string,
+7
View File
@@ -1,3 +1,10 @@
export * from './lib/stalker-main-container.component';
export * from './lib/stalker-inline-detail/stalker-inline-detail.component';
export * from './lib/stalker-collection-channels-list/stalker-collection-channels-list.component';
export * from './lib/stalker-favorites/stalker-favorites.component';
export * from './lib/stalker-catalog-facade.service';
export * from './lib/stalker-feature.routes';
export * from './lib/stalker-live-stream-layout/stalker-live-stream-layout.component';
export * from './lib/stalker-search/stalker-search.component';
export * from './lib/stalker-shell/stalker-shell.component';
export * from './lib/recently-viewed/recently-viewed.component';
@@ -10,7 +10,6 @@ import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PlayerService } from '../../services/player.service';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
@@ -27,8 +26,11 @@ import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import {
createLogger,
FavoritesContextService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem, StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
@@ -91,7 +93,7 @@ export class RecentlyViewedComponent {
private readonly collectionRefresh = createRefreshTrigger();
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly playerService = inject(PlayerService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
@@ -204,7 +206,7 @@ export class RecentlyViewedComponent {
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv');
readonly isEmbeddedPlayer = computed(() =>
this.playerService.isEmbeddedPlayer()
this.portalPlayer.isEmbeddedPlayer()
);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
@@ -341,7 +343,7 @@ export class RecentlyViewedComponent {
this.stalkerStore.setSelectedContentType(item.category_id);
switch (item.category_id) {
case 'itv':
case 'itv': {
this.setCategoryId('itv');
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
@@ -349,6 +351,7 @@ export class RecentlyViewedComponent {
this.isSelectedVodFavorite.set(false);
void this.selectLiveItem(item);
break;
}
case 'vod': {
// Normalize the item to ensure is_series flag is properly set
const normalizedItem = this.normalizeRecentItem(item);
@@ -420,9 +423,8 @@ export class RecentlyViewedComponent {
await this.loadEpgForChannel(item.id);
this.liveStreamUrl.set(playback.streamUrl);
if (this.isEmbeddedPlayer()) {
} else {
void this.playerService.openResolvedPlayback(playback, true);
if (!this.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Failed to resolve ITV playback', error);
@@ -91,7 +91,8 @@ export class StalkerCatalogFacadeService
this.stalkerStore.setLimit(limit);
}
setContentSortMode(_mode: PortalCatalogSortMode): void {
setContentSortMode(mode: PortalCatalogSortMode): void {
void mode;
// Stalker catalog content is server-paginated and does not support local sort modes.
}
@@ -106,9 +107,8 @@ export class StalkerCatalogFacadeService
return null;
}
getItemProgress(
_item: StalkerVodSource
): PortalCatalogItemProgress {
getItemProgress(item: StalkerVodSource): PortalCatalogItemProgress {
void item;
return {};
}
@@ -10,7 +10,6 @@ import { ActivatedRoute, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PlayerService } from '../../services/player.service';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
@@ -24,8 +23,11 @@ import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import {
createLogger,
FavoritesContextService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerFavoriteItem } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
@@ -86,7 +88,7 @@ export class StalkerFavoritesComponent {
private readonly playlistService = inject(PlaylistsService);
private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly playerService = inject(PlayerService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerFavorites');
@@ -156,7 +158,7 @@ export class StalkerFavoritesComponent {
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'itv');
readonly isEmbeddedPlayer = computed(() =>
this.playerService.isEmbeddedPlayer()
this.portalPlayer.isEmbeddedPlayer()
);
private epgPageSize = 10;
private epgChannelId: number | string | null = null;
@@ -423,9 +425,8 @@ export class StalkerFavoritesComponent {
await this.loadEpgForChannel(item.id);
this.liveStreamUrl.set(playback.streamUrl);
if (this.isEmbeddedPlayer()) {
} else {
void this.playerService.openResolvedPlayback(playback, true);
if (!this.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Failed to resolve ITV playback', error);
@@ -1,17 +1,8 @@
import { Provider } from '@angular/core';
import { Route } from '@angular/router';
import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service';
type ComponentLoader = NonNullable<Route['loadComponent']>;
export interface StalkerFeatureRouteOptions {
readonly catalogProviders?: Provider[];
readonly loadCategoryContentViewComponent: ComponentLoader;
readonly loadLiveStreamLayoutComponent: ComponentLoader;
readonly loadFavoritesComponent: ComponentLoader;
readonly loadRecentlyViewedComponent: ComponentLoader;
readonly loadSearchComponent: ComponentLoader;
}
const loadDownloadsComponent: ComponentLoader = () =>
import('@iptvnator/portal/downloads/feature').then(
(c) => c.DownloadsComponent
@@ -27,9 +18,32 @@ const loadStalkerMainContainerComponent: ComponentLoader = () =>
(c) => c.StalkerMainContainerComponent
);
export function createStalkerRoutes(
options: StalkerFeatureRouteOptions
): Route[] {
const loadStalkerLiveStreamLayoutComponent: ComponentLoader = () =>
import('./stalker-live-stream-layout/stalker-live-stream-layout.component').then(
(c) => c.StalkerLiveStreamLayoutComponent
);
const loadCategoryContentViewComponent: ComponentLoader = () =>
import('@iptvnator/portal/catalog/feature').then(
(c) => c.CategoryContentViewComponent
);
const loadStalkerFavoritesComponent: ComponentLoader = () =>
import('./stalker-favorites/stalker-favorites.component').then(
(c) => c.StalkerFavoritesComponent
);
const loadStalkerRecentlyViewedComponent: ComponentLoader = () =>
import('./recently-viewed/recently-viewed.component').then(
(c) => c.RecentlyViewedComponent
);
const loadStalkerSearchComponent: ComponentLoader = () =>
import('./stalker-search/stalker-search.component').then(
(c) => c.StalkerSearchComponent
);
export function createStalkerRoutes(): Route[] {
return [
{
path: 'stalker/:id',
@@ -42,7 +56,7 @@ export function createStalkerRoutes(
},
{
path: 'vod',
providers: options.catalogProviders ?? [],
providers: provideStalkerCatalogFacade(),
loadComponent: loadStalkerMainContainerComponent,
children: [
{
@@ -51,8 +65,7 @@ export function createStalkerRoutes(
api: 'stalker',
contentType: 'vod',
},
loadComponent:
options.loadCategoryContentViewComponent,
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
@@ -60,8 +73,7 @@ export function createStalkerRoutes(
api: 'stalker',
contentType: 'vod',
},
loadComponent:
options.loadCategoryContentViewComponent,
loadComponent: loadCategoryContentViewComponent,
},
],
},
@@ -72,11 +84,11 @@ export function createStalkerRoutes(
},
{
path: 'itv',
loadComponent: options.loadLiveStreamLayoutComponent,
loadComponent: loadStalkerLiveStreamLayoutComponent,
},
{
path: 'series',
providers: options.catalogProviders ?? [],
providers: provideStalkerCatalogFacade(),
loadComponent: loadStalkerMainContainerComponent,
children: [
{
@@ -85,8 +97,7 @@ export function createStalkerRoutes(
api: 'stalker',
contentType: 'series',
},
loadComponent:
options.loadCategoryContentViewComponent,
loadComponent: loadCategoryContentViewComponent,
},
{
path: ':categoryId',
@@ -94,22 +105,21 @@ export function createStalkerRoutes(
api: 'stalker',
contentType: 'series',
},
loadComponent:
options.loadCategoryContentViewComponent,
loadComponent: loadCategoryContentViewComponent,
},
],
},
{
path: 'favorites',
loadComponent: options.loadFavoritesComponent,
loadComponent: loadStalkerFavoritesComponent,
},
{
path: 'recent',
loadComponent: options.loadRecentlyViewedComponent,
loadComponent: loadStalkerRecentlyViewedComponent,
},
{
path: 'search',
loadComponent: options.loadSearchComponent,
loadComponent: loadStalkerSearchComponent,
},
{
path: 'downloads',
@@ -5,11 +5,13 @@ import {
input,
output,
} from '@angular/core';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { VodDetailsItem } from 'shared-interfaces';
import { VodDetailsComponent } from '../../portal-shared/vod-details/vod-details.component';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { VodDetailsItem } from 'shared-interfaces';
import {
StalkerSeriesViewComponent,
VodDetailsComponent,
} from 'components';
@Component({
selector: 'app-stalker-inline-detail',
@@ -27,7 +29,7 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
],
})
export class StalkerInlineDetailComponent {
readonly externalPlayback = inject(ExternalPlaybackService);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
@@ -1,5 +1,4 @@
@let isEmbeddedPlayer =
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) {
<div
@@ -1,5 +1,6 @@
@use '@angular/material' as mat;
@use '../../shared/styles/portal-layout' as portal;
@use '../../../../../../ui/styles/portal-layout' as portal;
@use '../../../../../../ui/styles/portal-sidebar';
@include portal.live-layout;
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
effect,
ElementRef,
inject,
@@ -26,35 +27,33 @@ import {
ResizableDirective,
} from 'components';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistsService, StalkerSessionService } from 'services';
import { PlaylistsService } from 'services';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import {
PORTAL_PLAYER,
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
import { createLogger } from '@iptvnator/portal/shared/util';
isWorkspaceLayoutRoute,
} from '@iptvnator/portal/shared/util';
import {
CategoryViewComponent,
PlaylistErrorViewComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
StalkerCategoryItem,
StalkerFavoriteItem,
StalkerItvChannel,
normalizeStalkerEntityId,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
styleUrls: [
'./stalker-live-stream-layout.component.scss',
'../../shared/styles/portal-sidebar.scss',
],
styleUrls: ['./stalker-live-stream-layout.component.scss'],
imports: [
CategoryViewComponent,
ChannelListItemComponent,
@@ -79,10 +78,8 @@ import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute);
readonly stalkerStore = inject(StalkerStore);
private readonly settingsStore = inject(SettingsStore);
private readonly playlistService = inject(PlaylistsService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly playerService = inject(PlayerService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerLiveStream');
@@ -106,7 +103,9 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
/** Player */
readonly player = this.settingsStore.player;
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
streamUrl = '';
/** EPG */
@@ -273,20 +272,15 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
async playChannel(item: StalkerItvChannel) {
this.stalkerStore.setSelectedItem(item);
const isEmbeddedPlayer =
this.player() === 'videojs' ||
this.player() === 'html5' ||
this.player() === 'artplayer';
try {
const playback = await this.stalkerStore.resolveItvPlayback(item);
this.loadEpgForChannel(item.id);
if (isEmbeddedPlayer) {
if (this.usesEmbeddedPlayer()) {
this.streamUrl = playback.streamUrl;
} else {
void this.playerService.openResolvedPlayback(playback, true);
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
this.logger.error('Playback failed', error);
@@ -1,4 +1,4 @@
@use '../../shared/styles/content-grid' as grid;
@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid;
:host {
display: flex;
+13
View File
@@ -1,5 +1,18 @@
export * from './lib/account-info/account-info.component';
export * from './lib/category-management-dialog/category-management-dialog.component';
export * from './lib/favorites/favorites.component';
export * from './lib/global-recently-viewed/global-recently-viewed.component';
export * from './lib/global-search-results/global-search-results.component';
export * from './lib/live-stream-layout/live-stream-layout.component';
export * from './lib/loading-overlay.component';
export * from './lib/player-dialog/player-dialog.component';
export * from './lib/portal-channels-list/portal-channels-list.component';
export * from './lib/recently-added/recently-added.component';
export * from './lib/recently-viewed/recently-viewed.component';
export * from './lib/search-results/search-results.component';
export * from './lib/serial-details/serial-details.component';
export * from './lib/vod-details/vod-details-route.component';
export * from './lib/xtream-main-container.component';
export * from './lib/xtream-catalog-facade.service';
export * from './lib/xtream-feature.routes';
export * from './lib/xtream-shell/xtream-shell.component';
@@ -15,18 +15,18 @@ import { Store } from '@ngrx/store';
import { TranslatePipe } from '@ngx-translate/core';
import { selectActivePlaylist } from 'm3u-state';
import { BehaviorSubject, switchMap } from 'rxjs';
import { PlayerService } from '../../services/player.service';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '@iptvnator/portal/shared/ui';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
createPortalCollectionContext,
filterCollectionBucket,
PORTAL_PLAYER,
queryParamSignal,
} from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
@@ -60,7 +60,7 @@ const XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY =
templateUrl: './favorites.component.html',
styleUrls: [
'./favorites.component.scss',
'../../shared/styles/portal-sidebar.scss',
'../../../../../shared/ui/src/lib/styles/portal-sidebar.scss',
],
})
export class FavoritesComponent implements OnInit {
@@ -69,7 +69,7 @@ export class FavoritesComponent implements OnInit {
private router = inject(Router);
private store = inject(Store);
private xtreamStore = inject(XtreamStore);
private playerService = inject(PlayerService);
private portalPlayer = inject(PORTAL_PLAYER);
private readonly favoritesCtx = inject(FavoritesContextService);
readonly currentPlaylist = this.xtreamStore.currentPlaylist;
@@ -124,7 +124,7 @@ export class FavoritesComponent implements OnInit {
() => this.selectedCategoryId() === 'live'
);
readonly isEmbeddedPlayer = computed(() =>
this.playerService.isEmbeddedPlayer()
this.portalPlayer.isEmbeddedPlayer()
);
readonly liveChannelSortMode = signal<LiveChannelSortMode>('server');
readonly liveChannelSortLabel = computed(() => {
@@ -1,11 +1,11 @@
import { Component } from '@angular/core';
import { RecentlyViewedComponent } from './recently-viewed.component';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RecentlyViewedComponent } from '../recently-viewed/recently-viewed.component';
@Component({
selector: 'app-global-recently-viewed',
imports: [RecentlyViewedComponent],
providers: [XtreamStore],
template: '<app-recently-viewed/>'
template: '<app-recently-viewed/>',
})
export class GlobalRecentlyViewedComponent {}
@@ -1,6 +1,6 @@
import { Component } from '@angular/core';
import { SearchResultsComponent } from './search-results.component';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { SearchResultsComponent } from '../search-results/search-results.component';
@Component({
selector: 'app-global-search-results',
@@ -1,5 +1,4 @@
@let isEmbeddedPlayer =
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
@let isEmbeddedPlayer = usesEmbeddedPlayer();
@if (!isWorkspaceLayout || selectedCategoryId()) {
<div
@@ -121,5 +120,4 @@
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
<!-- <app-mpv-player-bar /> -->
</div>
@@ -1,5 +1,6 @@
@use '@angular/material' as mat;
@use '../../shared/styles/portal-layout' as portal;
@use '../../../../../../ui/styles/portal-layout' as portal;
@use '../../../../../../ui/styles/portal-sidebar';
@include portal.live-layout;
@@ -24,23 +24,21 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { XtreamCategory } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { SettingsStore } from '../../services/settings-store.service';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { CategoryViewComponent, PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
PORTAL_PLAYER,
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
isWorkspaceLayoutRoute,
} from '@iptvnator/portal/shared/util';
import { FavoritesService, XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { XtreamCategory } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '@iptvnator/portal/xtream/feature';
} from '../category-management-dialog/category-management-dialog.component';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { FavoritesService } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -48,10 +46,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@Component({
selector: 'app-live-stream-layout',
templateUrl: './live-stream-layout.component.html',
styleUrls: [
'./live-stream-layout.component.scss',
'../../shared/styles/portal-sidebar.scss',
],
styleUrls: ['./live-stream-layout.component.scss'],
imports: [
CategoryViewComponent,
EpgViewComponent,
@@ -76,7 +71,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly favoritesService = inject(FavoritesService);
private readonly xtreamStore = inject(XtreamStore);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute);
@@ -119,7 +114,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
readonly player = this.settingsStore.player;
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
streamUrl: string;
favorites = new Map<number, boolean>();
@@ -250,11 +247,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
playLive(item: any) {
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.streamUrl = streamUrl;
const isEmbeddedPlayer =
this.player() === 'videojs' ||
this.player() === 'html5' ||
this.player() === 'artplayer';
if (isEmbeddedPlayer) {
if (this.usesEmbeddedPlayer()) {
return;
}
this.xtreamStore.openPlayer(streamUrl, item.title, item.poster_url);
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more