diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index affb8ec5b..36b85ae1d 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -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', diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 76e8c3fe7..55db30335 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -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(), ], }; diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 5e610c435..cf8ace6bb 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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' }, diff --git a/apps/web/src/app/portal-shared/portal-main-container.scss b/apps/web/src/app/portal-shared/portal-main-container.scss deleted file mode 100644 index 0a582a570..000000000 --- a/apps/web/src/app/portal-shared/portal-main-container.scss +++ /dev/null @@ -1 +0,0 @@ -@forward '../../../../../libs/portal/shared/ui/src/lib/styles/portal-main-container'; diff --git a/apps/web/src/app/portal-shared/season-container/season-container.component.ts b/apps/web/src/app/portal-shared/season-container/season-container.component.ts deleted file mode 100644 index ac599e118..000000000 --- a/apps/web/src/app/portal-shared/season-container/season-container.component.ts +++ /dev/null @@ -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>(); - readonly seriesId = input.required(); - readonly seriesTitle = input(''); - readonly isLoading = input(false); - readonly openingEpisodeId = input(null); - readonly activeEpisodeId = input(null); - - readonly isElectron = this.downloadsService.isAvailable; - - /** Current view mode for episodes (grid or list) */ - readonly viewMode = signal('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(); - readonly seasonSelected = output(); - - 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); - } - } -} diff --git a/apps/web/src/app/services/player.service.ts b/apps/web/src/app/services/player.service.ts index 4fe99c16e..9a82fbea6 100644 --- a/apps/web/src/app/services/player.service.ts +++ b/apps/web/src/app/services/player.service.ts @@ -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 ): Promise { + void _isLiveContent; return this.openResolvedPlayback( { streamUrl, diff --git a/apps/web/src/app/services/portal-navigation-actions.service.ts b/apps/web/src/app/services/portal-navigation-actions.service.ts index 55e906776..7ee39127a 100644 --- a/apps/web/src/app/services/portal-navigation-actions.service.ts +++ b/apps/web/src/app/services/portal-navigation-actions.service.ts @@ -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 diff --git a/apps/web/src/app/services/portal-playback-positions.service.ts b/apps/web/src/app/services/portal-playback-positions.service.ts new file mode 100644 index 000000000..ba987aa98 --- /dev/null +++ b/apps/web/src/app/services/portal-playback-positions.service.ts @@ -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 { + await this.dataSource.savePlaybackPosition(playlistId, data); + } + + async getPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise { + return this.dataSource.getPlaybackPosition( + playlistId, + contentXtreamId, + contentType + ); + } + + async getSeriesPlaybackPositions( + playlistId: string, + seriesXtreamId: number + ): Promise { + return this.dataSource.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); + } + + async getAllPlaybackPositions( + playlistId: string + ): Promise { + return this.dataSource.getAllPlaybackPositions(playlistId); + } + + async clearPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise { + await this.dataSource.clearPlaybackPosition( + playlistId, + contentXtreamId, + contentType + ); + } +} + +export const providePortalPlaybackPositions = () => [ + { + provide: PORTAL_PLAYBACK_POSITIONS, + useExisting: AppPortalPlaybackPositionsService, + }, +]; diff --git a/apps/web/src/app/services/workspace-shell-actions.service.ts b/apps/web/src/app/services/workspace-shell-actions.service.ts new file mode 100644 index 000000000..023c0c3ab --- /dev/null +++ b/apps/web/src/app/services/workspace-shell-actions.service.ts @@ -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, + }, + ]; +} diff --git a/apps/web/src/app/settings/settings.component.spec.ts b/apps/web/src/app/settings/settings.component.spec.ts index f5695abfe..f2d51787b 100644 --- a/apps/web/src/app/settings/settings.component.spec.ts +++ b/apps/web/src/app/settings/settings.component.spec.ts @@ -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) { + this._settings.set({ + ...this._settings(), + ...newSettings, + }); } } @@ -89,7 +89,7 @@ describe('SettingsComponent', () => { let fixture: ComponentFixture; 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), diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index c6155deee..02d712dd8 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -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 { diff --git a/apps/web/src/app/shared/components/about-dialog/about-dialog.component.ts b/apps/web/src/app/shared/components/about-dialog/about-dialog.component.ts deleted file mode 100644 index f480c518e..000000000 --- a/apps/web/src/app/shared/components/about-dialog/about-dialog.component.ts +++ /dev/null @@ -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: ` -
- logo
-

{{ 'ABOUT.TITLE' | translate }}

-

{{ 'ABOUT.DESCRIPTION' | translate }}

-

{{ 'ABOUT.VERSION' | translate }}: {{ appVersion }}

-

- GitHub  - - Twitter - - local_cafe -

-
- `, - styles: [ - ` - button { - text-transform: uppercase; - } - - a { - color: #fff; - } - - .mat-icon { - font-size: 32px; - } - `, - ], -}) -export class AboutDialogComponent { - public appVersion = inject(MAT_DIALOG_DATA); -} diff --git a/apps/web/src/app/shared/components/header/header.component.html b/apps/web/src/app/shared/components/header/header.component.html deleted file mode 100644 index 93f00daf1..000000000 --- a/apps/web/src/app/shared/components/header/header.component.html +++ /dev/null @@ -1,265 +0,0 @@ -
-
-
- iptvnator logo -
-
-
{{ title() }}
-
-
- - @if (isHome) { -
- search - -
- - - - - - - } -
diff --git a/apps/web/src/app/shared/components/header/header.component.scss b/apps/web/src/app/shared/components/header/header.component.scss deleted file mode 100644 index 45d92f2b2..000000000 --- a/apps/web/src/app/shared/components/header/header.component.scss +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/app/shared/components/header/header.component.ts b/apps/web/src/app/shared/components/header/header.component.ts deleted file mode 100644 index 70761ae30..000000000 --- a/apps/web/src/app/shared/components/header/header.component.ts +++ /dev/null @@ -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(); - readonly subtitle = input.required(); - readonly searchQuery = output(); - readonly globalSearchClicked = output(); - readonly globalRecentClicked = output(); - - 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 { - 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, - { - width: '600px', - data: { type }, - } - ); - } - - onSearchQueryUpdate(query: string): void { - this.searchQuery.emit(query); - } - - onGlobalSearchClick(): void { - this.globalSearchClicked.emit(); - } - - onGlobalRecentClick(): void { - this.globalRecentClicked.emit(); - } -} diff --git a/apps/web/src/app/shared/components/index.ts b/apps/web/src/app/shared/components/index.ts deleted file mode 100644 index 0119ab68a..000000000 --- a/apps/web/src/app/shared/components/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { - CategoryViewComponent, - ContentCardComponent, - FavoritesLayoutComponent, - PlaylistErrorViewComponent, - PortalCollectionLiveShellComponent, - PortalCollectionShellComponent, - PortalEmptyStateComponent, - SearchFormComponent, - SearchLayoutComponent, -} from '@iptvnator/portal/shared/ui'; -export * from './header/header.component'; diff --git a/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.html b/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.html deleted file mode 100644 index e7509d2fe..000000000 --- a/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.html +++ /dev/null @@ -1,39 +0,0 @@ - diff --git a/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.scss b/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.scss deleted file mode 100644 index 8720510c9..000000000 --- a/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.scss +++ /dev/null @@ -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; - } - } -} diff --git a/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.ts b/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.ts deleted file mode 100644 index e44bf44e6..000000000 --- a/apps/web/src/app/shared/components/mpv-player-bar/mpv-player-bar.component.ts +++ /dev/null @@ -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 { - await this.mpvPlayerService.closeStream(processId); - } */ - /* async playStream(processId: number): Promise { - await this.mpvPlayerService.playStream(processId); - } - - async pauseStream(processId: number): Promise { - await this.mpvPlayerService.pauseStream(processId); - } */ -} diff --git a/apps/web/src/app/shared/services/mpv-player.service.ts b/apps/web/src/app/shared/services/mpv-player.service.ts deleted file mode 100644 index 699dea68f..000000000 --- a/apps/web/src/app/shared/services/mpv-player.service.ts +++ /dev/null @@ -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([]); - 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( - 'get_active_mpv_processes' - ); - this.activeProcessesSubject.next(processes); - } catch (error) { - console.error('Failed to load active MPV processes:', error); - } - } - - async playStream(processId: number): Promise { - await invoke('mpv_play', { processId }); - } - - async pauseStream(processId: number): Promise { - await invoke('mpv_pause', { processId }); - } - - async closeStream(processId: number): Promise { - await invoke('close_mpv_process', { processId }); - } -} - */ diff --git a/apps/web/src/app/shared/styles/_content-grid.scss b/apps/web/src/app/shared/styles/_content-grid.scss deleted file mode 100644 index d611bf0f8..000000000 --- a/apps/web/src/app/shared/styles/_content-grid.scss +++ /dev/null @@ -1 +0,0 @@ -@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/content-grid'; diff --git a/apps/web/src/app/shared/styles/_portal-layout.scss b/apps/web/src/app/shared/styles/_portal-layout.scss deleted file mode 100644 index 6f3a668d5..000000000 --- a/apps/web/src/app/shared/styles/_portal-layout.scss +++ /dev/null @@ -1 +0,0 @@ -@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-layout'; diff --git a/apps/web/src/app/shared/styles/portal-sidebar.scss b/apps/web/src/app/shared/styles/portal-sidebar.scss deleted file mode 100644 index 95c30ea1f..000000000 --- a/apps/web/src/app/shared/styles/portal-sidebar.scss +++ /dev/null @@ -1 +0,0 @@ -@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-sidebar'; diff --git a/apps/web/src/app/stalker/stalker-feature-loaders.ts b/apps/web/src/app/stalker/stalker-feature-loaders.ts deleted file mode 100644 index fbb8570bc..000000000 --- a/apps/web/src/app/stalker/stalker-feature-loaders.ts +++ /dev/null @@ -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 - ), -}; diff --git a/apps/web/src/app/workspace/dashboard-placeholder.component.html b/apps/web/src/app/workspace/dashboard-placeholder.component.html deleted file mode 100644 index e280b0b6b..000000000 --- a/apps/web/src/app/workspace/dashboard-placeholder.component.html +++ /dev/null @@ -1,161 +0,0 @@ -
- -
-
-
-

- - Workspace -

-

Your IPTV sources,
one place.

-

- Jump back into your active source, switch providers - instantly, or explore what's new across your library. -

- -
- -
-
- - -
-
-
- folder_open -
-
- {{ stats().total }} - Total Sources -
-
- -
-
- stream -
-
- {{ stats().xtream }} - Xtream -
-
- -
-
- router -
-
- {{ stats().stalker }} - Stalker -
-
- -
-
- playlist_play -
-
- {{ stats().m3u }} - M3U -
-
-
- - -
-
-

Recent Sources

- - arrow_forward - View all - -
- - @if (recentPlaylists().length > 0) { - - } @else { -
-
- library_add -
-

No sources yet

-

Add a playlist in Library to start using the workspace.

- - add - Add Source - -
- } -
-
diff --git a/apps/web/src/app/workspace/dashboard-placeholder.component.scss b/apps/web/src/app/workspace/dashboard-placeholder.component.scss deleted file mode 100644 index 9c997d50e..000000000 --- a/apps/web/src/app/workspace/dashboard-placeholder.component.scss +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/app/workspace/dashboard-placeholder.component.ts b/apps/web/src/app/workspace/dashboard-placeholder.component.ts deleted file mode 100644 index a7de9e4cf..000000000 --- a/apps/web/src/app/workspace/dashboard-placeholder.component.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/web/src/app/workspace/favorites-context.service.ts b/apps/web/src/app/workspace/favorites-context.service.ts deleted file mode 100644 index e95285d1c..000000000 --- a/apps/web/src/app/workspace/favorites-context.service.ts +++ /dev/null @@ -1 +0,0 @@ -export { FavoritesContextService } from '@iptvnator/portal/shared/util'; diff --git a/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts b/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts deleted file mode 100644 index 45b21ed63..000000000 --- a/apps/web/src/app/xtream-electron/xtream-feature-loaders.ts +++ /dev/null @@ -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 - ), -}; diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 0bcc69957..4c9d220ee 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -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; } - diff --git a/libs/playlist/import/feature/project.json b/libs/playlist/import/feature/project.json new file mode 100644 index 000000000..d6b3a0d50 --- /dev/null +++ b/libs/playlist/import/feature/project.json @@ -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" + } + } +} diff --git a/libs/playlist/import/feature/src/index.ts b/libs/playlist/import/feature/src/index.ts new file mode 100644 index 000000000..58e6f4268 --- /dev/null +++ b/libs/playlist/import/feature/src/index.ts @@ -0,0 +1 @@ +export * from './lib/add-playlist-dialog/add-playlist-dialog.component'; diff --git a/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.html b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html similarity index 100% rename from apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.html rename to libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.html diff --git a/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts similarity index 83% rename from apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts rename to libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts index 8dc1f54b8..965bc7f4d 100644 --- a/apps/web/src/app/shared/components/add-playlist/add-playlist-dialog.component.ts +++ b/libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts @@ -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(); } } diff --git a/apps/web/src/app/home/file-upload/drag-drop-file-upload.directive.ts b/libs/playlist/import/feature/src/lib/file-upload/drag-drop-file-upload.directive.ts similarity index 62% rename from apps/web/src/app/home/file-upload/drag-drop-file-upload.directive.ts rename to libs/playlist/import/feature/src/lib/file-upload/drag-drop-file-upload.directive.ts index 04970c3a5..846985893 100644 --- a/apps/web/src/app/home/file-upload/drag-drop-file-upload.directive.ts +++ b/libs/playlist/import/feature/src/lib/file-upload/drag-drop-file-upload.directive.ts @@ -10,28 +10,30 @@ import { selector: '[appDragDropFileUpload]', }) export class DragDropFileUploadDirective { - defaultColor = 'rgb(255 255 255 / 15%)'; - @Output() fileDropped = new EventEmitter(); + readonly defaultColor = 'rgb(255 255 255 / 15%)'; + @Output() fileDropped = new EventEmitter(); @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); } } diff --git a/apps/web/src/app/home/file-upload/file-upload.component.html b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.html similarity index 100% rename from apps/web/src/app/home/file-upload/file-upload.component.html rename to libs/playlist/import/feature/src/lib/file-upload/file-upload.component.html diff --git a/apps/web/src/app/home/file-upload/file-upload.component.scss b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.scss similarity index 100% rename from apps/web/src/app/home/file-upload/file-upload.component.scss rename to libs/playlist/import/feature/src/lib/file-upload/file-upload.component.scss diff --git a/apps/web/src/app/home/file-upload/file-upload.component.ts b/libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts similarity index 100% rename from apps/web/src/app/home/file-upload/file-upload.component.ts rename to libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts diff --git a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.html b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html similarity index 100% rename from apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.html rename to libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.html diff --git a/apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts similarity index 100% rename from apps/web/src/app/home/stalker-portal-import/stalker-portal-import.component.ts rename to libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts diff --git a/apps/web/src/app/home/text-import/text-import.component.html b/libs/playlist/import/feature/src/lib/text-import/text-import.component.html similarity index 100% rename from apps/web/src/app/home/text-import/text-import.component.html rename to libs/playlist/import/feature/src/lib/text-import/text-import.component.html diff --git a/apps/web/src/app/home/text-import/text-import.component.scss b/libs/playlist/import/feature/src/lib/text-import/text-import.component.scss similarity index 100% rename from apps/web/src/app/home/text-import/text-import.component.scss rename to libs/playlist/import/feature/src/lib/text-import/text-import.component.scss diff --git a/apps/web/src/app/home/text-import/text-import.component.spec.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts similarity index 100% rename from apps/web/src/app/home/text-import/text-import.component.spec.ts rename to libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts diff --git a/apps/web/src/app/home/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts similarity index 100% rename from apps/web/src/app/home/text-import/text-import.component.ts rename to libs/playlist/import/feature/src/lib/text-import/text-import.component.ts diff --git a/apps/web/src/app/home/url-upload/url-upload.component.html b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html similarity index 100% rename from apps/web/src/app/home/url-upload/url-upload.component.html rename to libs/playlist/import/feature/src/lib/url-upload/url-upload.component.html diff --git a/apps/web/src/app/home/url-upload/url-upload.component.spec.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts similarity index 87% rename from apps/web/src/app/home/url-upload/url-upload.component.spec.ts rename to libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts index 4d9263bd4..c01df36e1 100644 --- a/apps/web/src/app/home/url-upload/url-upload.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.spec.ts @@ -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(); })); diff --git a/apps/web/src/app/home/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts similarity index 100% rename from apps/web/src/app/home/url-upload/url-upload.component.ts rename to libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts diff --git a/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.html b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html similarity index 100% rename from apps/web/src/app/home/xtream-code-import/xtream-code-import.component.html rename to libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.html diff --git a/apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts similarity index 100% rename from apps/web/src/app/home/xtream-code-import/xtream-code-import.component.ts rename to libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts diff --git a/libs/playlist/m3u/feature-player/project.json b/libs/playlist/m3u/feature-player/project.json new file mode 100644 index 000000000..ee3e59f10 --- /dev/null +++ b/libs/playlist/m3u/feature-player/project.json @@ -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" + } + } +} diff --git a/libs/playlist/m3u/feature-player/src/index.ts b/libs/playlist/m3u/feature-player/src/index.ts new file mode 100644 index 000000000..2251b23a5 --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/playlist-player-actions'; +export * from './lib/video-player/video-player.component'; diff --git a/libs/playlist/m3u/feature-player/src/lib/playlist-player-actions.ts b/libs/playlist/m3u/feature-player/src/lib/playlist-player-actions.ts new file mode 100644 index 000000000..2e52f330b --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/playlist-player-actions.ts @@ -0,0 +1,8 @@ +import { InjectionToken } from '@angular/core'; + +export interface PlaylistPlayerActions { + openSettings(): void; +} + +export const PLAYLIST_PLAYER_ACTIONS = + new InjectionToken('PLAYLIST_PLAYER_ACTIONS'); diff --git a/apps/web/src/app/home/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html similarity index 100% rename from apps/web/src/app/home/video-player/video-player.component.html rename to libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html diff --git a/apps/web/src/app/home/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss similarity index 100% rename from apps/web/src/app/home/video-player/video-player.component.scss rename to libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss diff --git a/apps/web/src/app/home/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts similarity index 100% rename from apps/web/src/app/home/video-player/video-player.component.spec.ts rename to libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts diff --git a/apps/web/src/app/home/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts similarity index 97% rename from apps/web/src/app/home/video-player/video-player.component.ts rename to libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 23ef7a52a..ae661b8d3 100644 --- a/apps/web/src/app/home/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -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(); } /** diff --git a/libs/portal/catalog/feature/project.json b/libs/portal/catalog/feature/project.json new file mode 100644 index 000000000..c57de76ad --- /dev/null +++ b/libs/portal/catalog/feature/project.json @@ -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" + } + } +} diff --git a/libs/portal/catalog/feature/src/index.ts b/libs/portal/catalog/feature/src/index.ts new file mode 100644 index 000000000..2e6d41f5e --- /dev/null +++ b/libs/portal/catalog/feature/src/index.ts @@ -0,0 +1 @@ +export * from './lib/category-content-view/category-content-view.component'; diff --git a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.html b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html similarity index 100% rename from apps/web/src/app/portal-shared/category-content-view/category-content-view.component.html rename to libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.html diff --git a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss similarity index 100% rename from apps/web/src/app/portal-shared/category-content-view/category-content-view.component.scss rename to libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss diff --git a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.ts b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts similarity index 93% rename from apps/web/src/app/portal-shared/category-content-view/category-content-view.component.ts rename to libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts index 449228f32..ca531286c 100644 --- a/apps/web/src/app/portal-shared/category-content-view/category-content-view.component.ts +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts @@ -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 { const positions = - await this.dataSource.getAllPlaybackPositions(playlistId); + await this.playbackPositions.getAllPlaybackPositions(playlistId); const positionsMap = new Map(); const seriesMap = new Map(); @@ -352,7 +354,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy { playlistId: string, vodId: number ): Promise { - 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 = diff --git a/libs/portal/shared/ui/src/index.ts b/libs/portal/shared/ui/src/index.ts index 0fcf87851..75df182b8 100644 --- a/libs/portal/shared/ui/src/index.ts +++ b/libs/portal/shared/ui/src/index.ts @@ -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'; diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html index 1c42b5cfa..8b2901cc3 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.html @@ -73,5 +73,4 @@ } - diff --git a/apps/web/src/app/shared/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss similarity index 100% rename from apps/web/src/app/shared/components/grid-list/grid-list.component.scss rename to libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss diff --git a/apps/web/src/app/shared/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts similarity index 98% rename from apps/web/src/app/shared/components/grid-list/grid-list.component.ts rename to libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index 4682e783d..3157b348d 100644 --- a/apps/web/src/app/shared/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -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; diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 872505ac1..cd6cdc6ec 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/portal-external-playback.ts b/libs/portal/shared/util/src/lib/portal-external-playback.ts new file mode 100644 index 000000000..b1cdeb752 --- /dev/null +++ b/libs/portal/shared/util/src/lib/portal-external-playback.ts @@ -0,0 +1,14 @@ +import { InjectionToken, Signal } from '@angular/core'; +import { ExternalPlayerSession } from 'shared-interfaces'; + +export interface PortalExternalPlayback { + readonly activeSession: Signal; + readonly visibleSession: Signal; + dismissActiveSession(): void; + closeSession( + session: ExternalPlayerSession | null | undefined + ): Promise; +} + +export const PORTAL_EXTERNAL_PLAYBACK = + new InjectionToken('PORTAL_EXTERNAL_PLAYBACK'); diff --git a/libs/portal/shared/util/src/lib/portal-playback-positions.ts b/libs/portal/shared/util/src/lib/portal-playback-positions.ts new file mode 100644 index 000000000..4f4b66b07 --- /dev/null +++ b/libs/portal/shared/util/src/lib/portal-playback-positions.ts @@ -0,0 +1,60 @@ +import { InjectionToken } from '@angular/core'; +import { PlaybackPositionData } from 'shared-interfaces'; + +export interface PortalPlaybackPositions { + savePlaybackPosition( + playlistId: string, + data: PlaybackPositionData + ): Promise; + getPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise; + getSeriesPlaybackPositions( + playlistId: string, + seriesXtreamId: number + ): Promise; + getAllPlaybackPositions(playlistId: string): Promise; + clearPlaybackPosition( + playlistId: string, + contentXtreamId: number, + contentType: 'vod' | 'episode' + ): Promise; +} + +export const PORTAL_PLAYBACK_POSITIONS = + new InjectionToken('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; +} diff --git a/libs/portal/shared/util/src/lib/portal-player.ts b/libs/portal/shared/util/src/lib/portal-player.ts index 8ad88ebb8..dab6e8db6 100644 --- a/libs/portal/shared/util/src/lib/portal-player.ts +++ b/libs/portal/shared/util/src/lib/portal-player.ts @@ -5,6 +5,7 @@ import { } from 'shared-interfaces'; export interface PortalPlayer { + isEmbeddedPlayer(): boolean; openPlayer( streamUrl: string, title: string, diff --git a/apps/web/src/app/shared/services/remote-channel-navigation.util.ts b/libs/portal/shared/util/src/lib/remote-channel-navigation.ts similarity index 100% rename from apps/web/src/app/shared/services/remote-channel-navigation.util.ts rename to libs/portal/shared/util/src/lib/remote-channel-navigation.ts diff --git a/libs/portal/stalker/feature/src/index.ts b/libs/portal/stalker/feature/src/index.ts index df35caaa1..971b52efe 100644 --- a/libs/portal/stalker/feature/src/index.ts +++ b/libs/portal/stalker/feature/src/index.ts @@ -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'; diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html b/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.html similarity index 100% rename from apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html rename to libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.html diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts similarity index 97% rename from apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts rename to libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts index 17808cd84..8d1d58a46 100644 --- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts +++ b/libs/portal/stalker/feature/src/lib/recently-viewed/recently-viewed.component.ts @@ -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); diff --git a/apps/web/src/app/stalker/stalker-catalog-facade.service.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts similarity index 96% rename from apps/web/src/app/stalker/stalker-catalog-facade.service.ts rename to libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts index f3b83a7c2..27af65dbb 100644 --- a/apps/web/src/app/stalker/stalker-catalog-facade.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-facade.service.ts @@ -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 {}; } diff --git a/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.html b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.html similarity index 100% rename from apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.html rename to libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.html diff --git a/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.scss b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.scss similarity index 100% rename from apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.scss rename to libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.scss diff --git a/apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts similarity index 100% rename from apps/web/src/app/stalker/stalker-collection-channels-list/stalker-collection-channels-list.component.ts rename to libs/portal/stalker/feature/src/lib/stalker-collection-channels-list/stalker-collection-channels-list.component.ts diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html b/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.html similarity index 100% rename from apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.html rename to libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.html diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts similarity index 97% rename from apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts rename to libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts index 1c9894d01..316d35c56 100644 --- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-favorites/stalker-favorites.component.ts @@ -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); diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts index da9fb8a93..2235e227b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -1,17 +1,8 @@ -import { Provider } from '@angular/core'; import { Route } from '@angular/router'; +import { provideStalkerCatalogFacade } from './stalker-catalog-facade.service'; type ComponentLoader = NonNullable; -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', diff --git a/apps/web/src/app/stalker/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html similarity index 100% rename from apps/web/src/app/stalker/stalker-inline-detail/stalker-inline-detail.component.html rename to libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html diff --git a/apps/web/src/app/stalker/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts similarity index 81% rename from apps/web/src/app/stalker/stalker-inline-detail/stalker-inline-detail.component.ts rename to libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts index b678c2923..02018b01e 100644 --- a/apps/web/src/app/stalker/stalker-inline-detail/stalker-inline-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts @@ -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(null); readonly isSeries = input(false); diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html similarity index 98% rename from apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html rename to libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 9f2e60a0a..b963e1f95 100644 --- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -1,5 +1,4 @@ -@let isEmbeddedPlayer = - player() === 'videojs' || player() === 'html5' || player() === 'artplayer'; +@let isEmbeddedPlayer = usesEmbeddedPlayer(); @if (!isWorkspaceLayout || stalkerStore.selectedCategoryId()) {
+ 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); diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html similarity index 100% rename from apps/web/src/app/stalker/stalker-search/stalker-search.component.html rename to libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.scss b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.scss similarity index 80% rename from apps/web/src/app/stalker/stalker-search/stalker-search.component.scss rename to libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.scss index 7c42e9336..193cfc9f9 100644 --- a/apps/web/src/app/stalker/stalker-search/stalker-search.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.scss @@ -1,4 +1,4 @@ -@use '../../shared/styles/content-grid' as grid; +@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid; :host { display: flex; diff --git a/apps/web/src/app/stalker/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts similarity index 100% rename from apps/web/src/app/stalker/stalker-search/stalker-search.component.ts rename to libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts diff --git a/libs/portal/xtream/feature/src/index.ts b/libs/portal/xtream/feature/src/index.ts index 60bfc9b2f..8d90adee5 100644 --- a/libs/portal/xtream/feature/src/index.ts +++ b/libs/portal/xtream/feature/src/index.ts @@ -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'; diff --git a/apps/web/src/app/xtream-electron/account-info/account-info.component.html b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.html similarity index 100% rename from apps/web/src/app/xtream-electron/account-info/account-info.component.html rename to libs/portal/xtream/feature/src/lib/account-info/account-info.component.html diff --git a/apps/web/src/app/xtream-electron/account-info/account-info.component.scss b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss similarity index 100% rename from apps/web/src/app/xtream-electron/account-info/account-info.component.scss rename to libs/portal/xtream/feature/src/lib/account-info/account-info.component.scss diff --git a/apps/web/src/app/xtream-electron/account-info/account-info.component.ts b/libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts similarity index 100% rename from apps/web/src/app/xtream-electron/account-info/account-info.component.ts rename to libs/portal/xtream/feature/src/lib/account-info/account-info.component.ts diff --git a/apps/web/src/app/xtream-electron/favorites/favorites.component.html b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.html similarity index 100% rename from apps/web/src/app/xtream-electron/favorites/favorites.component.html rename to libs/portal/xtream/feature/src/lib/favorites/favorites.component.html diff --git a/apps/web/src/app/xtream-electron/favorites/favorites.component.scss b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.scss similarity index 100% rename from apps/web/src/app/xtream-electron/favorites/favorites.component.scss rename to libs/portal/xtream/feature/src/lib/favorites/favorites.component.scss diff --git a/apps/web/src/app/xtream-electron/favorites/favorites.component.ts b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts similarity index 96% rename from apps/web/src/app/xtream-electron/favorites/favorites.component.ts rename to libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts index a91a17ab9..48b202b39 100644 --- a/apps/web/src/app/xtream-electron/favorites/favorites.component.ts +++ b/libs/portal/xtream/feature/src/lib/favorites/favorites.component.ts @@ -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('server'); readonly liveChannelSortLabel = computed(() => { diff --git a/apps/web/src/app/xtream-electron/recently-viewed/global-recently-viewed.component.ts b/libs/portal/xtream/feature/src/lib/global-recently-viewed/global-recently-viewed.component.ts similarity index 69% rename from apps/web/src/app/xtream-electron/recently-viewed/global-recently-viewed.component.ts rename to libs/portal/xtream/feature/src/lib/global-recently-viewed/global-recently-viewed.component.ts index 46d561b5d..349acb473 100644 --- a/apps/web/src/app/xtream-electron/recently-viewed/global-recently-viewed.component.ts +++ b/libs/portal/xtream/feature/src/lib/global-recently-viewed/global-recently-viewed.component.ts @@ -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: '' + template: '', }) export class GlobalRecentlyViewedComponent {} diff --git a/apps/web/src/app/xtream-electron/search-results/global-search-results.component.ts b/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts similarity index 79% rename from apps/web/src/app/xtream-electron/search-results/global-search-results.component.ts rename to libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts index 865366eee..61dca7165 100644 --- a/apps/web/src/app/xtream-electron/search-results/global-search-results.component.ts +++ b/libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts @@ -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', diff --git a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html similarity index 96% rename from apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.html rename to libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index bb0e7fb38..45117093d 100644 --- a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -1,5 +1,4 @@ -@let isEmbeddedPlayer = - player() === 'videojs' || player() === 'html5' || player() === 'artplayer'; +@let isEmbeddedPlayer = usesEmbeddedPlayer(); @if (!isWorkspaceLayout || selectedCategoryId()) {
} -
diff --git a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss similarity index 94% rename from apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss rename to libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss index 1e352293d..8ce052c26 100644 --- a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -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; diff --git a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts similarity index 92% rename from apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts rename to libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index ebcf7e1bf..d5a6448d2 100644 --- a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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(); @@ -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); diff --git a/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.html b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html similarity index 100% rename from apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.html rename to libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.html diff --git a/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.scss b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss similarity index 100% rename from apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.scss rename to libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.scss diff --git a/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.ts b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts similarity index 80% rename from apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.ts rename to libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts index 89688db6e..74a27115b 100644 --- a/apps/web/src/app/xtream-electron/player-dialog/player-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/player-dialog/player-dialog.component.ts @@ -6,9 +6,9 @@ import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; import { PlayerContentInfo } from 'shared-interfaces'; import { WebPlayerViewComponent } from 'shared-portals'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; export interface PlayerDialogData { streamUrl: string; @@ -35,7 +35,7 @@ export class PlayerDialogComponent { readonly data = inject(MAT_DIALOG_DATA); private snackBar = inject(MatSnackBar); private translateService = inject(TranslateService); - private readonly xtreamStore = inject(XtreamStore); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); readonly title: string; readonly streamUrl: string; @@ -54,11 +54,14 @@ export class PlayerDialogComponent { // Save every 15 seconds if (now - this.lastSaveTime > 15000) { this.lastSaveTime = now; - this.xtreamStore.savePosition(this.data.contentInfo.playlistId, { - ...this.data.contentInfo, - positionSeconds: Math.floor(event.currentTime), - durationSeconds: Math.floor(event.duration), - }); + void this.playbackPositions.savePlaybackPosition( + this.data.contentInfo.playlistId, + { + ...this.data.contentInfo, + positionSeconds: Math.floor(event.currentTime), + durationSeconds: Math.floor(event.duration), + } + ); } } diff --git a/apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html similarity index 100% rename from apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.html rename to libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html diff --git a/apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.scss b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss similarity index 100% rename from apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.scss rename to libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss diff --git a/apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts similarity index 100% rename from apps/web/src/app/xtream-electron/portal-channels-list/portal-channels-list.component.ts rename to libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts diff --git a/apps/web/src/app/xtream-electron/recently-added/recently-added.component.html b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html similarity index 100% rename from apps/web/src/app/xtream-electron/recently-added/recently-added.component.html rename to libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html diff --git a/apps/web/src/app/xtream-electron/recently-added/recently-added.component.scss b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss similarity index 100% rename from apps/web/src/app/xtream-electron/recently-added/recently-added.component.scss rename to libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.scss diff --git a/apps/web/src/app/xtream-electron/recently-added/recently-added.component.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts similarity index 100% rename from apps/web/src/app/xtream-electron/recently-added/recently-added.component.ts rename to libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts diff --git a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.html b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html similarity index 100% rename from apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.html rename to libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html diff --git a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.scss b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.scss similarity index 97% rename from apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.scss rename to libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.scss index 0c20ce2b9..7aa1b2cbb 100644 --- a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.scss +++ b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.scss @@ -1,4 +1,4 @@ -@use '../../shared/styles/content-grid' as grid; +@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid; :host { display: flex; diff --git a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.ts b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts similarity index 98% rename from apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.ts rename to libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts index 7a4764611..a7840ab05 100644 --- a/apps/web/src/app/xtream-electron/recently-viewed/recently-viewed.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.ts @@ -20,7 +20,6 @@ import { TranslatePipe } from '@ngx-translate/core'; import groupBy from 'lodash/groupBy'; import { firstValueFrom } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; -import { PlayerService } from '../../services/player.service'; import { ContentCardComponent } from '@iptvnator/portal/shared/ui'; import { PortalCollectionLiveShellComponent, @@ -41,6 +40,7 @@ import { } from '@iptvnator/portal/shared/util'; import { createLogger } from '@iptvnator/portal/shared/util'; import { FavoritesContextService } from '@iptvnator/portal/shared/util'; +import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; @@ -97,7 +97,7 @@ export class RecentlyViewedComponent { private router = inject(Router); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); - private readonly playerService = inject(PlayerService); + private readonly portalPlayer = inject(PORTAL_PLAYER); private dialogData = inject(MAT_DIALOG_DATA, { optional: true }); private readonly logger = createLogger('XtreamRecentlyViewed'); private readonly favoritesCtx = inject(FavoritesContextService); @@ -247,7 +247,7 @@ export class RecentlyViewedComponent { ); readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'live'); readonly isEmbeddedPlayer = computed(() => - this.playerService.isEmbeddedPlayer() + this.portalPlayer.isEmbeddedPlayer() ); readonly liveChannelSortMode = signal('server'); readonly liveChannelSortLabel = computed(() => { diff --git a/apps/web/src/app/xtream-electron/search-results/search-results.component.html b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.html similarity index 100% rename from apps/web/src/app/xtream-electron/search-results/search-results.component.html rename to libs/portal/xtream/feature/src/lib/search-results/search-results.component.html diff --git a/apps/web/src/app/xtream-electron/search-results/search-results.component.scss b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.scss similarity index 97% rename from apps/web/src/app/xtream-electron/search-results/search-results.component.scss rename to libs/portal/xtream/feature/src/lib/search-results/search-results.component.scss index 3ac08fca5..d5c323bef 100644 --- a/apps/web/src/app/xtream-electron/search-results/search-results.component.scss +++ b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.scss @@ -1,4 +1,4 @@ -@use '../../shared/styles/content-grid' as grid; +@use '../../../../../shared/ui/src/lib/styles/content-grid' as grid; :host { display: flex; diff --git a/apps/web/src/app/xtream-electron/search-results/search-results.component.spec.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts similarity index 100% rename from apps/web/src/app/xtream-electron/search-results/search-results.component.spec.ts rename to libs/portal/xtream/feature/src/lib/search-results/search-results.component.spec.ts diff --git a/apps/web/src/app/xtream-electron/search-results/search-results.component.ts b/libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts similarity index 100% rename from apps/web/src/app/xtream-electron/search-results/search-results.component.ts rename to libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts diff --git a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html similarity index 92% rename from apps/web/src/app/xtream-electron/serial-details/serial-details.component.html rename to libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 661dd9375..da237b5bd 100644 --- a/apps/web/src/app/xtream-electron/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -1,6 +1,5 @@ @let item = selectedItem(); @if (item?.info) { - - @if (item.info?.releaseDate) { {{ @@ -30,7 +28,6 @@ } - @if (item.info?.cast) {
@@ -48,7 +45,6 @@ } - @if (!isFavorite()) { @@ -98,7 +97,9 @@ [class.play-btn--state-stop]="externalPrimaryButtonState() === 'stop'" > @if (externalPrimaryLabel(); as label) { @@ -125,12 +126,18 @@ } @if (isElectron()) { @if (isDownloaded()) { - } @else if (isDownloading()) { - @@ -152,7 +159,6 @@ /> } - @if (item.info?.youtube_trailer) {

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

diff --git a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts similarity index 70% rename from apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts rename to libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index d21c3ac33..107c6c231 100644 --- a/apps/web/src/app/xtream-electron/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -12,34 +12,27 @@ import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; import { ActivatedRoute } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { ContentHeroComponent } from 'components'; +import { ContentHeroComponent, PortalInlinePlayerComponent } from 'components'; +import { SafePipe } from '@iptvnator/pipes'; import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, + createLogger, + getPortalPlaybackProgressPercent, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { DownloadsService, SettingsStore } from 'services'; +import { + PlaybackPositionData, PlayerContentInfo, ResolvedPortalPlayback, XtreamVodDetails, } from 'shared-interfaces'; -import { DownloadsService } from '../../services/downloads.service'; -import { ExternalPlaybackService } from '../../services/external-playback.service'; -import { PlayerService } from '../../services/player.service'; -import { SettingsStore } from '../../services/settings-store.service'; -import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { SafePipe } from '@iptvnator/pipes'; -import { createLogger } from '@iptvnator/portal/shared/util'; -/** - * Route-based VOD details container for Xtream. - * - * This is a "smart" component that: - * - Reads route params (vodId, categoryId) - * - Fetches data via XtreamStore - * - Manages playback and favorites - * - * Used for route-based navigation: /xtreams/:id/vod/:categoryId/:vodId - */ @Component({ templateUrl: './vod-details-route.component.html', - styleUrls: ['../../portal-shared/detail-view.scss'], + styleUrls: ['../../../../../../ui/components/src/lib/styles/detail-view.scss'], imports: [ ContentHeroComponent, MatIcon, @@ -50,18 +43,20 @@ import { createLogger } from '@iptvnator/portal/shared/util'; ], }) export class VodDetailsRouteComponent implements OnInit, OnDestroy { - private location = inject(Location); - private settingsStore = inject(SettingsStore); - private route = inject(ActivatedRoute); + private readonly location = inject(Location); + private readonly settingsStore = inject(SettingsStore); + private readonly route = inject(ActivatedRoute); private readonly xtreamStore = inject(XtreamStore); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); private readonly downloadsService = inject(DownloadsService); - private readonly externalPlayback = inject(ExternalPlaybackService); - private readonly playerService = inject(PlayerService); + private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly logger = createLogger('VodDetailsRoute'); private readonly detailsInitDone = signal(false); readonly inlinePlayback = signal(null); + readonly vodPlaybackPosition = signal(null); readonly theme = this.settingsStore.theme; readonly isElectron = this.downloadsService.isAvailable; @@ -71,6 +66,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; readonly detailsError = this.xtreamStore.detailsError; private lastSaveTime = 0; + private unsubscribePositionUpdates: (() => void) | null = null; readonly matchedExternalPlayback = computed(() => { const session = this.externalPlayback.activeSession(); const vodId = Number(this.route.snapshot.params.vodId); @@ -139,46 +135,64 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { return this.isExternalStopAction() ? 'stop' : 'idle'; }); + readonly vodPlaybackProgress = computed(() => + getPortalPlaybackProgressPercent(this.vodPlaybackPosition()) + ); readonly hasPlaybackPosition = computed(() => { - const vodId = this.route.snapshot.params.vodId; - const inProgress = this.xtreamStore.isInProgress(Number(vodId), 'vod'); + const inProgress = + this.vodPlaybackProgress() > 0 && this.vodPlaybackProgress() < 90; this.logger.debug('hasPlaybackPosition check', { - vodId, + vodId: this.route.snapshot.params.vodId, inProgress, }); return inProgress; }); - /** Check if VOD is already downloaded */ readonly isDownloaded = computed(() => { const vodId = Number(this.route.snapshot.params.vodId); const playlistId = this.xtreamStore.currentPlaylist()?.id; if (!playlistId) return false; - // Access downloads signal to make this reactive this.downloadsService.downloads(); return this.downloadsService.isDownloaded(vodId, playlistId, 'vod'); }); - /** Check if VOD is currently downloading */ readonly isDownloading = computed(() => { const vodId = Number(this.route.snapshot.params.vodId); const playlistId = this.xtreamStore.currentPlaylist()?.id; if (!playlistId) return false; - // Access downloads signal to make this reactive this.downloadsService.downloads(); return this.downloadsService.isDownloading(vodId, playlistId, 'vod'); }); constructor() { - // Route can initialize before currentPlaylist signal is ready. - // Retry initialization once playlist becomes available. effect(() => { const playlistId = this.xtreamStore.currentPlaylist()?.id; + const vodId = Number(this.route.snapshot.params.vodId); if (!playlistId || this.detailsInitDone()) return; - this.initializeVodDetails(playlistId); + this.initializeVodDetails(playlistId, vodId); this.detailsInitDone.set(true); }); + + if (window.electron?.onPlaybackPositionUpdate) { + this.unsubscribePositionUpdates = + window.electron.onPlaybackPositionUpdate( + (data: PlaybackPositionData) => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const vodId = Number(this.route.snapshot.params.vodId); + + if ( + data.contentType !== 'vod' || + data.playlistId !== playlistId || + data.contentXtreamId !== vodId + ) { + return; + } + + this.vodPlaybackPosition.set(data); + } + ); + } } ngOnInit(): void { @@ -187,25 +201,25 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.logger.warn('Deferring VOD details init: playlist not ready'); return; } - this.initializeVodDetails(currentPlaylist.id); + this.initializeVodDetails(currentPlaylist.id, Number(this.route.snapshot.params.vodId)); this.detailsInitDone.set(true); } - ngOnDestroy() { + ngOnDestroy(): void { this.inlinePlayback.set(null); + this.unsubscribePositionUpdates?.(); this.xtreamStore.setSelectedItem(null); } - playVod(vodItem: XtreamVodDetails) { + playVod(vodItem: XtreamVodDetails): void { this.addToRecentlyViewed(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); - - // Use route param vodId if available to match ngOnInit logic, otherwise fallback to item id const routeVodId = this.route.snapshot.params.vodId; const id = routeVodId ? Number(routeVodId) : Number( - vodItem.movie_data?.stream_id || (vodItem as any).stream_id + vodItem.movie_data?.stream_id || + (vodItem as { stream_id?: number }).stream_id ); this.logger.debug('playVod resolved ID', { id, vodItem }); @@ -217,7 +231,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { }; const playback: ResolvedPortalPlayback = { streamUrl, - title: vodItem.info.name ?? vodItem?.movie_data?.name, + title: vodItem.info.name ?? vodItem.movie_data?.name, thumbnail: vodItem.info.movie_image, contentInfo, }; @@ -225,15 +239,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.startPlayback(playback); } - resumeVod(vodItem: XtreamVodDetails) { + resumeVod(vodItem: XtreamVodDetails): void { this.addToRecentlyViewed(); const vodId = Number(this.route.snapshot.params.vodId); - const position = this.xtreamStore - .playbackPositions() - .get(`vod_${vodId}`); + const position = this.vodPlaybackPosition(); const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); - // Use vodId from route (same as above) const contentInfo: PlayerContentInfo = { playlistId: this.xtreamStore.currentPlaylist().id, contentXtreamId: vodId, @@ -241,7 +252,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { }; const playback: ResolvedPortalPlayback = { streamUrl, - title: vodItem.info.name ?? vodItem?.movie_data?.name, + title: vodItem.info.name ?? vodItem.movie_data?.name, thumbnail: vodItem.info.movie_image, startTime: position?.positionSeconds, contentInfo, @@ -269,38 +280,25 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } formatPosition(): string { - const vodId = Number(this.route.snapshot.params.vodId); - const position = this.xtreamStore.playbackPositions().get(`vod_${vodId}`); + const position = this.vodPlaybackPosition(); if (!position) return ''; - + const date = new Date(0); date.setSeconds(position.positionSeconds); const timeString = date.toISOString().substr(11, 8); - return timeString.startsWith('00:') ? timeString.substr(3) : timeString; + return timeString.startsWith('00:') + ? timeString.substr(3) + : timeString; } - toggleFavorite() { + toggleFavorite(): void { this.xtreamStore.toggleFavorite( this.route.snapshot.params.vodId, this.xtreamStore.currentPlaylist().id ); } - private addToRecentlyViewed() { - this.xtreamStore.addRecentItem({ - contentId: this.route.snapshot.params.vodId, - playlist: this.xtreamStore.currentPlaylist, - }); - } - - private initializeVodDetails(playlistId: string): void { - const { categoryId, vodId } = this.route.snapshot.params; - this.xtreamStore.fetchVodDetailsWithMetadata({ vodId, categoryId }); - this.xtreamStore.checkFavoriteStatus(vodId, playlistId); - this.xtreamStore.loadVodPosition(playlistId, Number(vodId)); - } - - goBack() { + goBack(): void { this.closeInlinePlayer(); this.location.back(); } @@ -321,11 +319,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { if (now - this.lastSaveTime <= 15000) return; this.lastSaveTime = now; - void this.xtreamStore.savePosition(playback.contentInfo.playlistId, { + const position: PlaybackPositionData = { ...playback.contentInfo, positionSeconds: Math.floor(event.currentTime), durationSeconds: Math.floor(event.duration), - }); + }; + void this.playbackPositions.savePlaybackPosition( + playback.contentInfo.playlistId, + position + ); + this.vodPlaybackPosition.set(position); } showCopyNotification(): void { @@ -338,23 +341,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ); } - private startPlayback(playback: ResolvedPortalPlayback): void { - this.lastSaveTime = 0; - if (this.playerService.isEmbeddedPlayer()) { - this.inlinePlayback.set(playback); - return; - } - - this.closeInlinePlayer(); - void this.playerService.openResolvedPlayback(playback, true); - } - - async downloadVod(vodItem: XtreamVodDetails) { + async downloadVod(vodItem: XtreamVodDetails): Promise { const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); const routeVodId = this.route.snapshot.params.vodId; const id = routeVodId ? Number(routeVodId) - : Number(vodItem.movie_data?.stream_id || (vodItem as any).stream_id); + : Number( + vodItem.movie_data?.stream_id || + (vodItem as { stream_id?: number }).stream_id + ); const playlist = this.xtreamStore.currentPlaylist(); @@ -362,7 +357,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { playlistId: playlist.id, xtreamId: id, contentType: 'vod', - title: vodItem.info?.name || vodItem?.movie_data?.name || 'Unknown', + title: vodItem.info?.name ?? vodItem.movie_data?.name ?? 'Unknown', url: streamUrl, posterUrl: vodItem.info?.movie_image, headers: { @@ -373,7 +368,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { }); } - async playFromLocal() { + async playFromLocal(): Promise { const vodId = Number(this.route.snapshot.params.vodId); const playlistId = this.xtreamStore.currentPlaylist()?.id; if (!playlistId) return; @@ -388,4 +383,44 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { await this.downloadsService.playDownload(filePath); } } + + private addToRecentlyViewed(): void { + this.xtreamStore.addRecentItem({ + contentId: this.route.snapshot.params.vodId, + playlist: this.xtreamStore.currentPlaylist, + }); + } + + private initializeVodDetails(playlistId: string, vodId: number): void { + const { categoryId } = this.route.snapshot.params; + this.xtreamStore.fetchVodDetailsWithMetadata({ + vodId: String(vodId), + categoryId, + }); + this.xtreamStore.checkFavoriteStatus(vodId, playlistId); + void this.loadVodPlaybackPosition(playlistId, vodId); + } + + private startPlayback(playback: ResolvedPortalPlayback): void { + this.lastSaveTime = 0; + if (this.portalPlayer.isEmbeddedPlayer()) { + this.inlinePlayback.set(playback); + return; + } + + this.closeInlinePlayer(); + void this.portalPlayer.openResolvedPlayback(playback, true); + } + + private async loadVodPlaybackPosition( + playlistId: string, + vodId: number + ): Promise { + const position = await this.playbackPositions.getPlaybackPosition( + playlistId, + vodId, + 'vod' + ); + this.vodPlaybackPosition.set(position); + } } diff --git a/apps/web/src/app/xtream-electron/xtream-catalog-facade.service.ts b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts similarity index 97% rename from apps/web/src/app/xtream-electron/xtream-catalog-facade.service.ts rename to libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts index f3434c3cb..964ef5271 100644 --- a/apps/web/src/app/xtream-electron/xtream-catalog-facade.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-catalog-facade.service.ts @@ -12,7 +12,8 @@ const SORT_STORAGE_KEY = 'xtream-category-sort-mode'; @Injectable() export class XtreamCatalogFacadeService - implements PortalCatalogFacade, Record, unknown> + implements + PortalCatalogFacade, Record, unknown> { private readonly xtreamStore = inject(XtreamStore); diff --git a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts index 84f68082a..ace223ea4 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts @@ -1,20 +1,8 @@ -import { Provider } from '@angular/core'; import { Route } from '@angular/router'; +import { provideXtreamCatalogFacade } from './xtream-catalog-facade.service'; type ComponentLoader = NonNullable; -export interface XtreamFeatureRouteOptions { - readonly catalogProviders?: Provider[]; - readonly loadLiveStreamLayoutComponent: ComponentLoader; - readonly loadCategoryContentViewComponent: ComponentLoader; - readonly loadVodDetailsRouteComponent: ComponentLoader; - readonly loadSerialDetailsComponent: ComponentLoader; - readonly loadFavoritesComponent: ComponentLoader; - readonly loadRecentlyViewedComponent: ComponentLoader; - readonly loadSearchResultsComponent: ComponentLoader; - readonly loadRecentlyAddedComponent: ComponentLoader; -} - const loadDownloadsComponent: ComponentLoader = () => import('@iptvnator/portal/downloads/feature').then( (c) => c.DownloadsComponent @@ -30,9 +18,45 @@ const loadXtreamMainContainerComponent: ComponentLoader = () => (c) => c.XtreamMainContainerComponent ); -export function createXtreamRoutes( - options: XtreamFeatureRouteOptions -): Route[] { +const loadLiveStreamLayoutComponent: ComponentLoader = () => + import('./live-stream-layout/live-stream-layout.component').then( + (c) => c.LiveStreamLayoutComponent + ); + +const loadCategoryContentViewComponent: ComponentLoader = () => + import('@iptvnator/portal/catalog/feature').then( + (c) => c.CategoryContentViewComponent + ); + +const loadFavoritesComponent: ComponentLoader = () => + import('./favorites/favorites.component').then((c) => c.FavoritesComponent); + +const loadRecentlyViewedComponent: ComponentLoader = () => + import('./recently-viewed/recently-viewed.component').then( + (c) => c.RecentlyViewedComponent + ); + +const loadSearchResultsComponent: ComponentLoader = () => + import('./search-results/search-results.component').then( + (c) => c.SearchResultsComponent + ); + +const loadRecentlyAddedComponent: ComponentLoader = () => + import('./recently-added/recently-added.component').then( + (c) => c.RecentlyAddedComponent + ); + +const loadVodDetailsRouteComponent: ComponentLoader = () => + import('./vod-details/vod-details-route.component').then( + (c) => c.VodDetailsRouteComponent + ); + +const loadSerialDetailsComponent: ComponentLoader = () => + import('./serial-details/serial-details.component').then( + (c) => c.SerialDetailsComponent + ); + +export function createXtreamRoutes(): Route[] { return [ { path: 'xtreams/:id', @@ -45,73 +69,67 @@ export function createXtreamRoutes( }, { path: 'live', - loadComponent: options.loadLiveStreamLayoutComponent, + loadComponent: loadLiveStreamLayoutComponent, children: [ { path: ':categoryId', - loadComponent: - options.loadLiveStreamLayoutComponent, + loadComponent: loadLiveStreamLayoutComponent, }, ], }, { path: 'vod', - providers: options.catalogProviders ?? [], + providers: provideXtreamCatalogFacade(), loadComponent: loadXtreamMainContainerComponent, children: [ { path: '', - loadComponent: - options.loadCategoryContentViewComponent, + loadComponent: loadCategoryContentViewComponent, }, { path: ':categoryId', - loadComponent: - options.loadCategoryContentViewComponent, + loadComponent: loadCategoryContentViewComponent, }, { path: ':categoryId/:vodId', - loadComponent: - options.loadVodDetailsRouteComponent, + loadComponent: loadVodDetailsRouteComponent, }, ], }, { path: 'series', - providers: options.catalogProviders ?? [], + providers: provideXtreamCatalogFacade(), loadComponent: loadXtreamMainContainerComponent, children: [ { path: '', - loadComponent: - options.loadCategoryContentViewComponent, + loadComponent: loadCategoryContentViewComponent, }, { path: ':categoryId', - loadComponent: - options.loadCategoryContentViewComponent, + loadComponent: loadCategoryContentViewComponent, }, { path: ':categoryId/:serialId', - loadComponent: options.loadSerialDetailsComponent, + loadComponent: loadSerialDetailsComponent, }, ], }, { path: 'favorites', - loadComponent: options.loadFavoritesComponent, + loadComponent: loadFavoritesComponent, }, { path: 'recent', - loadComponent: options.loadRecentlyViewedComponent, + loadComponent: loadRecentlyViewedComponent, }, { path: 'search', - loadComponent: options.loadSearchResultsComponent, + loadComponent: loadSearchResultsComponent, }, { path: 'recently-added', - loadComponent: options.loadRecentlyAddedComponent, + loadComponent: loadRecentlyAddedComponent, }, { path: 'downloads', diff --git a/libs/services/src/index.ts b/libs/services/src/index.ts index ad09928c0..9f055a8f9 100644 --- a/libs/services/src/index.ts +++ b/libs/services/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/data.service'; export * from './lib/database-electron.service'; export * from './lib/downloads.service'; export * from './lib/epg.service'; +export * from './lib/epg-progress.service'; export * from './lib/playback-position.service'; export * from './lib/playlists.service'; export * from './lib/portal-status.service'; diff --git a/apps/web/src/app/services/epg-progress.service.ts b/libs/services/src/lib/epg-progress.service.ts similarity index 52% rename from apps/web/src/app/services/epg-progress.service.ts rename to libs/services/src/lib/epg-progress.service.ts index c4bd1f7f2..37caa2d2d 100644 --- a/apps/web/src/app/services/epg-progress.service.ts +++ b/libs/services/src/lib/epg-progress.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal, computed } from '@angular/core'; +import { Injectable, computed, signal } from '@angular/core'; export interface EpgImportProgress { url: string; @@ -8,50 +8,48 @@ export interface EpgImportProgress { queuePosition?: number; } -/** - * Service to track EPG import progress across multiple sources. - * Uses signals for reactive state management. - */ @Injectable({ providedIn: 'root' }) export class EpgProgressService { - private _imports = signal>(new Map()); - private _initialized = false; + private readonly importsMap = signal>( + new Map() + ); + private initialized = false; - /** All current import progress entries */ - readonly imports = computed(() => Array.from(this._imports().values())); - - /** Whether any imports are currently loading */ + readonly imports = computed(() => Array.from(this.importsMap().values())); readonly hasActiveImports = computed(() => - this.imports().some((i) => i.status === 'loading') + this.imports().some((item) => item.status === 'loading') ); - - /** Count of active imports */ - readonly activeCount = computed(() => - this.imports().filter((i) => i.status === 'loading').length + readonly activeCount = computed( + () => this.imports().filter((item) => item.status === 'loading').length ); - - /** Queued imports waiting to be processed */ readonly queuedImports = computed(() => this.imports() - .filter((i) => i.status === 'queued') - .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)) + .filter((item) => item.status === 'queued') + .sort( + (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) + ) ); - - /** Count of queued imports */ readonly queuedCount = computed(() => this.queuedImports().length); - - /** Whether the panel should be visible */ readonly isVisible = computed(() => this.imports().length > 0); constructor() { this.initializeListener(); } - private initializeListener(): void { - if (this._initialized) return; - this._initialized = true; + dismiss(url: string): void { + this.removeImport(url); + } + + dismissAll(): void { + this.importsMap.set(new Map()); + } + + private initializeListener(): void { + if (this.initialized) { + return; + } + this.initialized = true; - // Listen for EPG progress updates from Electron if (window.electron?.onEpgProgress) { window.electron.onEpgProgress((data) => { this.updateProgress(data); @@ -60,33 +58,22 @@ export class EpgProgressService { } private updateProgress(progress: EpgImportProgress): void { - this._imports.update((current) => { + this.importsMap.update((current) => { const updated = new Map(current); updated.set(progress.url, progress); return updated; }); - // Auto-remove completed/error entries after delay if (progress.status === 'complete' || progress.status === 'error') { setTimeout(() => this.removeImport(progress.url), 5000); } } private removeImport(url: string): void { - this._imports.update((current) => { + this.importsMap.update((current) => { const updated = new Map(current); updated.delete(url); return updated; }); } - - /** Manually dismiss an import notification */ - dismiss(url: string): void { - this.removeImport(url); - } - - /** Dismiss all notifications */ - dismissAll(): void { - this._imports.set(new Map()); - } } diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index 5a886c3aa..91f13a9e4 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -9,15 +9,27 @@ export * from './lib/content-hero/content-hero.component'; export * from './lib/epg-list/epg-item-description/epg-item-description.component'; export * from './lib/epg-list/epg-list-item/epg-list-item.component'; export * from './lib/epg-list/epg-list.component'; +export * from './lib/epg-progress-panel/epg-progress-panel.component'; +export * from './lib/external-player-info-dialog/external-player-info-dialog.component'; +export * from './lib/external-playback-dock/external-playback-dock.component'; +export * from './lib/filter-sort-menu/filter-sort-menu.component'; export * from './lib/html-video-player/html-video-player.component'; export * from './lib/info-overlay/info-overlay.component'; export * from './lib/multi-epg/multi-epg-container.component'; export * from './lib/multi-epg/overlay-ref.token'; export * from './lib/playlist-switcher/playlist-switcher.component'; +export * from './lib/portal-inline-player/portal-inline-player.component'; +export * from './lib/progress-capsule/progress-capsule.component'; export * from './lib/recent-playlists/playlist-info/playlist-info.component'; export * from './lib/recent-playlists/playlist-item/playlist-item.component'; export * from './lib/recent-playlists/recent-playlists.component'; +export * from './lib/season-container/season-container.component'; +export * from './lib/stalker-favorites-button/stalker-favorites-button.component'; +export * from './lib/stalker-series-view/stalker-series-view.component'; export * from './lib/video-player/sidebar/sidebar.component'; export * from './lib/video-player/toolbar/toolbar.component'; export * from './lib/vjs-player/vjs-player.component'; +export * from './lib/vod-details/vod-details.component'; +export * from './lib/watched-badge/watched-badge.component'; +export * from './lib/web-player-view/web-player-view.component'; export * from './lib/resizable/resizable.directive'; diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html b/libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.html similarity index 98% rename from apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html rename to libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.html index 6009e0b0c..72e464490 100644 --- a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.html +++ b/libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.html @@ -18,7 +18,6 @@
- @for (item of activeImports; track item.url) {
@@ -75,7 +74,6 @@
} - @if (queuedImports.length > 0) {
diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss b/libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.scss similarity index 63% rename from apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss rename to libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.scss index 2c2dac771..f36c1c3d4 100644 --- a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.scss +++ b/libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.scss @@ -1,7 +1,3 @@ -// EPG Progress Panel - Broadcast Control Room Aesthetic -// Sleek, modern design with glassmorphism and subtle glow effects - -// CSS Custom Properties for theming :host { --epg-panel-bg: rgba(18, 22, 28, 0.92); --epg-panel-bg-solid: #12161c; @@ -31,7 +27,6 @@ --epg-queue-border: rgba(167, 139, 250, 0.15); } -// Light theme overrides :host-context(.light-theme), :host-context([data-theme='light']), :host-context(.mat-app-background.light) { @@ -79,7 +74,6 @@ overflow: hidden; border: 1px solid var(--epg-panel-border); - // Subtle glow effect on top edge &::before { content: ''; position: absolute; @@ -97,7 +91,6 @@ ); } - // Entry animation animation: slideInFromLeft 0.35s cubic-bezier(0.16, 1, 0.3, 1); } @@ -106,6 +99,7 @@ opacity: 0; transform: translateX(-24px) scale(0.96); } + to { opacity: 1; transform: translateX(0) scale(1); @@ -163,6 +157,7 @@ 100% { filter: drop-shadow(0 0 4px var(--epg-accent-glow)); } + 50% { filter: drop-shadow(0 0 10px var(--epg-accent-glow)); } @@ -173,7 +168,6 @@ overflow-y: auto; padding: 4px 0; - // Custom scrollbar &::-webkit-scrollbar { width: 6px; } @@ -267,229 +261,138 @@ opacity: 1; } - mat-progress-bar { - margin-bottom: 10px; - border-radius: 2px; - height: 4px; - - ::ng-deep { - .mdc-linear-progress__buffer { - background-color: var(--epg-progress-track); - } - - .mdc-linear-progress__bar-inner { - border-color: var(--epg-accent); - } - - .mdc-linear-progress__buffer-bar { - background-color: var(--epg-progress-track); - } - } - } - - .item-stats { - font-size: 11px; - color: var(--epg-text-tertiary); - display: flex; - align-items: center; - gap: 8px; - - .stat-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - background: var(--epg-icon-bg); - border-radius: 6px; - font-weight: 600; - font-variant-numeric: tabular-nums; - font-size: 12px; - color: var(--epg-text-secondary); - - mat-icon { - font-size: 14px; - width: 14px; - height: 14px; - opacity: 0.7; - } - } - } - - // Status variations &.status-loading { - .status-icon-wrapper { - background: var(--epg-icon-bg); - } - - .item-header mat-icon { + .status-icon-wrapper mat-icon { color: var(--epg-accent); } - - .item-stats { - color: var(--epg-text-tertiary); - } } &.status-complete { - .status-icon-wrapper { - background: var(--epg-success-glow); - } - - .item-header mat-icon { + .status-icon-wrapper mat-icon { color: var(--epg-success); filter: drop-shadow(0 0 6px var(--epg-success-glow)); } - - .item-stats { - color: var(--epg-success); - - .stat-badge { - background: var(--epg-success-glow); - color: var(--epg-success); - } - } } &.status-error { - .status-icon-wrapper { - background: var(--epg-error-glow); - } - - .item-header mat-icon { + .status-icon-wrapper mat-icon { color: var(--epg-error); filter: drop-shadow(0 0 6px var(--epg-error-glow)); } - - .error-text { - color: var(--epg-error); - font-size: 11px; - font-weight: 500; - } - } -} - -// Queue section styles -.queue-section { - //margin: 8px; - padding: 12px; - border-radius: 10px; - animation: fadeIn 0.3s ease; - - .queue-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 10px; - padding-bottom: 8px; - border-bottom: 1px solid var(--epg-queue-border); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--epg-queued); - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - } - } - - .queue-list { - display: flex; - flex-direction: column; - gap: 6px; - } - - .queue-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: rgba(167, 139, 250, 0.04); - border-radius: 6px; - transition: all 0.2s ease; - - &:hover { - background: rgba(167, 139, 250, 0.08); - - .dismiss-btn { - opacity: 1; - } - } - - .queue-position { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: 6px; - background: var(--epg-queued-glow); - color: var(--epg-queued); - font-size: 11px; - font-weight: 700; - flex-shrink: 0; - } - - .queue-url { - flex: 1; - font-size: 11px; - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--epg-text-secondary); - font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace; - letter-spacing: -0.02em; - } - - .dismiss-btn { - width: 22px; - height: 22px; - padding: 0; - line-height: 22px; - border-radius: 4px; - opacity: 0; - transition: all 0.2s ease; - color: var(--epg-text-tertiary); - flex-shrink: 0; - - mat-icon { - font-size: 14px; - width: 14px; - height: 14px; - } - } - } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(4px); - } - to { - opacity: 1; - transform: translateY(0); } } .spinning { - animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + animation: spin 1.2s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } -// Responsive adjustments -@media (max-width: 400px) { +.item-stats { + display: flex; + align-items: center; + gap: 8px; + padding-left: 42px; + min-height: 24px; + + .stat-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; + background: var(--epg-item-border); + color: var(--epg-text-secondary); + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + } + + .error-text { + font-size: 12px; + color: var(--epg-error); + } +} + +.queue-section { + margin: 8px 6px 6px; + padding: 10px 12px; + border-radius: 12px; + background: var(--epg-queue-bg); + border: 1px solid var(--epg-queue-border); +} + +.queue-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 12px; + font-weight: 600; + color: var(--epg-text-primary); + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: var(--epg-queued); + } +} + +.queue-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.queue-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 8px; + color: var(--epg-text-secondary); + + .queue-position { + width: 18px; + text-align: center; + font-size: 11px; + font-weight: 700; + color: var(--epg-queued); + } + + .queue-url { + flex: 1; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: 'SF Mono', 'Fira Code', 'Monaco', monospace; + } + + .dismiss-btn { + width: 24px; + height: 24px; + padding: 2px; + line-height: 24px; + color: var(--epg-text-tertiary); + } +} + +@media (max-width: 700px) { .epg-progress-panel { left: 12px; right: 12px; diff --git a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts b/libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.ts similarity index 72% rename from apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts rename to libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.ts index 7d56bb10f..0dbb1c713 100644 --- a/apps/web/src/app/shared/epg-progress-panel/epg-progress-panel.component.ts +++ b/libs/ui/components/src/lib/epg-progress-panel/epg-progress-panel.component.ts @@ -4,10 +4,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; -import { - EpgImportProgress, - EpgProgressService, -} from '../../services/epg-progress.service'; +import { EpgImportProgress, EpgProgressService } from 'services'; @Component({ selector: 'app-epg-progress-panel', @@ -23,23 +20,23 @@ import { styleUrl: './epg-progress-panel.component.scss', }) export class EpgProgressPanelComponent { - private epgProgress = inject(EpgProgressService); + private readonly epgProgress = inject(EpgProgressService); readonly imports = this.epgProgress.imports; readonly isVisible = this.epgProgress.isVisible; readonly queuedCount = this.epgProgress.queuedCount; readonly activeCount = this.epgProgress.activeCount; - /** Active and completed imports (not queued) */ get activeImports() { - return this.imports().filter((i) => i.status !== 'queued'); + return this.imports().filter((item) => item.status !== 'queued'); } - /** Queued imports waiting to be processed */ get queuedImports() { return this.imports() - .filter((i) => i.status === 'queued') - .sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0)); + .filter((item) => item.status === 'queued') + .sort( + (a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0) + ); } getStatusIcon(status: EpgImportProgress['status']): string { @@ -61,10 +58,10 @@ export class EpgProgressPanelComponent { getDisplayUrl(url: string): string { try { - const urlObj = new URL(url); - return urlObj.hostname + urlObj.pathname.split('/').pop(); + const urlObject = new URL(url); + return urlObject.hostname + urlObject.pathname.split('/').pop(); } catch { - return url.length > 40 ? url.substring(0, 40) + '...' : url; + return url.length > 40 ? `${url.substring(0, 40)}...` : url; } } diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.html b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html similarity index 100% rename from apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.html rename to libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.html diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.scss b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss similarity index 100% rename from apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.scss rename to libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.scss diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.spec.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts similarity index 100% rename from apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.spec.ts rename to libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.spec.ts diff --git a/apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.ts b/libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts similarity index 100% rename from apps/web/src/app/shared/components/external-playback-dock/external-playback-dock.component.ts rename to libs/ui/components/src/lib/external-playback-dock/external-playback-dock.component.ts diff --git a/apps/web/src/app/shared/components/external-player-info-dialog/external-player-info-dialog.component.ts b/libs/ui/components/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts similarity index 100% rename from apps/web/src/app/shared/components/external-player-info-dialog/external-player-info-dialog.component.ts rename to libs/ui/components/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts diff --git a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html b/libs/ui/components/src/lib/filter-sort-menu/filter-sort-menu.component.html similarity index 98% rename from apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html rename to libs/ui/components/src/lib/filter-sort-menu/filter-sort-menu.component.html index 269f0cdb0..fb7056325 100644 --- a/apps/web/src/app/shared/components/filter-sort-menu/filter-sort-menu.component.html +++ b/libs/ui/components/src/lib/filter-sort-menu/filter-sort-menu.component.html @@ -1,5 +1,4 @@ -
-
-