mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
refactor: update nx monorepo structure
This commit is contained in:
1 parent
9834c9fc0d
commit
0ad1eeb785
193 files changed
+1908
-3370
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
<a
|
||||
href="http://twitter.com/share?text=IPTVnator — 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
|
||||
),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
File renamed without changes.
+7
-8
@@ -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();
|
||||
}
|
||||
}
|
||||
+9
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-8
@@ -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();
|
||||
}));
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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');
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+9
-16
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/category-content-view/category-content-view.component';
|
||||
File renamed without changes.
File renamed without changes.
+29
-27
@@ -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 =
|
||||
@@ -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';
|
||||
-1
@@ -73,5 +73,4 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- <app-mpv-player-bar /> -->
|
||||
</div>
|
||||
File renamed without changes.
+2
-2
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
File renamed without changes.
@@ -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';
|
||||
File renamed without changes.
+11
-9
@@ -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);
|
||||
+4
-4
@@ -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 {};
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+9
-8
@@ -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',
|
||||
|
||||
File renamed without changes.
+7
-5
@@ -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
-2
@@ -1,5 +1,4 @@
|
||||
@let isEmbeddedPlayer =
|
||||
player() === 'videojs' || player() === 'html5' || player() === 'artplayer';
|
||||
@let isEmbeddedPlayer = usesEmbeddedPlayer();
|
||||
|
||||
@if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) {
|
||||
<div
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+20
-26
@@ -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);
|
||||
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
||||
@use '../../shared/styles/content-grid' as grid;
|
||||
@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
File renamed without changes.
@@ -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';
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
-6
@@ -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(() => {
|
||||
+2
-2
@@ -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
-1
@@ -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
-3
@@ -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>
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+14
-21
@@ -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
Reference in new issue
Block a user