refactor: streamline layout and styling for live stream component

- Consolidated SCSS styles for the live stream layout, reducing redundancy and improving maintainability.
- Introduced a new portal layout mixin for better structure.
- Enhanced sidebar and content container styles for improved user experience.
- Updated TypeScript component to utilize new styles and improve readability.
- Refactored channel list and recently viewed components for consistency in design.
- Optimized playlist service and store interactions for better performance.
- Added new translations for enhanced user interface clarity.
- Improved database schema with additional indexing for EPG programs.
This commit is contained in:
4gray committed 2026-03-08 11:25:26 +01:00
1 parent fb4553c55f
commit c6bd861b49
41 files changed
+985 -916

No files matched your search

@@ -87,8 +87,7 @@ contextBridge.exposeInMainWorld('electron', {
data: ExternalPlayerSession
) => callback(data);
ipcRenderer.on(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
return () =>
ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
return () => ipcRenderer.off(EXTERNAL_PLAYER_SESSION_UPDATE, handler);
},
// DB save content progress listener
onDbSaveContentProgress: (callback: (count: number) => void) => {
@@ -178,6 +177,8 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('EPG_CHECK_FRESHNESS', { urls, maxAgeHours }),
searchEpgPrograms: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('EPG_DB_SEARCH_PROGRAMS', searchTerm, limit),
getNowPlayingPrograms: (options?: { category?: string; limit?: number }) =>
ipcRenderer.invoke('EPG_DB_GET_NOW_PLAYING', options ?? {}),
setMpvPlayerPath: (mpvPlayerPath: string) =>
ipcRenderer.invoke('SET_MPV_PLAYER_PATH', mpvPlayerPath),
setVlcPlayerPath: (vlcPlayerPath: string) =>
@@ -197,8 +198,7 @@ contextBridge.exposeInMainWorld('electron', {
url: string;
params: Record<string, string>;
requestId?: string;
}) =>
ipcRenderer.invoke('XTREAM_REQUEST', payload),
}) => ipcRenderer.invoke('XTREAM_REQUEST', payload),
// Database operations
dbCreatePlaylist: (playlist: any) =>
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
@@ -264,9 +264,25 @@ contextBridge.exposeInMainWorld('electron', {
searchTerm: string,
types: string[],
excludeHidden?: boolean
) => ipcRenderer.invoke('DB_SEARCH_CONTENT', playlistId, searchTerm, types, excludeHidden),
dbGlobalSearch: (searchTerm: string, types: string[], excludeHidden?: boolean) =>
ipcRenderer.invoke('DB_GLOBAL_SEARCH', searchTerm, types, excludeHidden),
) =>
ipcRenderer.invoke(
'DB_SEARCH_CONTENT',
playlistId,
searchTerm,
types,
excludeHidden
),
dbGlobalSearch: (
searchTerm: string,
types: string[],
excludeHidden?: boolean
) =>
ipcRenderer.invoke(
'DB_GLOBAL_SEARCH',
searchTerm,
types,
excludeHidden
),
dbGetRecentlyViewed: () => ipcRenderer.invoke('DB_GET_RECENTLY_VIEWED'),
dbClearRecentlyViewed: () => ipcRenderer.invoke('DB_CLEAR_RECENTLY_VIEWED'),
// Favorites
@@ -352,7 +368,12 @@ contextBridge.exposeInMainWorld('electron', {
episodeNumber?: number;
// Playlist info for auto-creation if needed
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
+1
View File
@@ -85,6 +85,7 @@ export default class App {
? {
titleBarStyle: 'hidden',
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 20 },
}
: {}),
});
@@ -329,6 +329,66 @@ ipcMain.handle('EPG_DB_CLEAR_ALL', async () => {
}
});
/**
* Get currently airing programs across all channels (batch, for On Air Now widget)
* Returns programs where start <= now <= stop, joined with channel info.
*/
ipcMain.handle(
'EPG_DB_GET_NOW_PLAYING',
async (
_event,
{ category, limit = 25 }: { category?: string; limit?: number } = {}
) => {
try {
const db = await getDatabase();
const now = new Date().toISOString();
// Build base query using Drizzle ORM (consistent with other handlers)
// stop >= now is highly selective with idx_epg_programs_stop,
// filtering out all past programs before checking start <= now.
const results = await db
.select({
channel_id: schema.epgPrograms.channelId,
title: schema.epgPrograms.title,
description: schema.epgPrograms.description,
category: schema.epgPrograms.category,
start: schema.epgPrograms.start,
stop: schema.epgPrograms.stop,
channel_name: schema.epgChannels.displayName,
channel_icon: schema.epgChannels.iconUrl,
})
.from(schema.epgPrograms)
.leftJoin(
schema.epgChannels,
eq(schema.epgPrograms.channelId, schema.epgChannels.id)
)
.where(
category && category !== 'all'
? and(
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now),
sql`LOWER(${schema.epgPrograms.category}) LIKE LOWER(${'%' + category + '%'})`
)
: and(
lte(schema.epgPrograms.start, now),
gte(schema.epgPrograms.stop, now)
)
)
.orderBy(schema.epgChannels.displayName)
.limit(limit);
return results;
} catch (error) {
console.error(
loggerLabel,
'Error getting now playing programs:',
error
);
throw error;
}
}
);
/**
* Get EPG statistics
*/
+13 -13
View File
@@ -1,6 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Params } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
@@ -11,9 +10,9 @@ import {
Playlist,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_UPDATE,
XtreamCodeActions,
XTREAM_RESPONSE,
XTREAM_REQUEST,
XTREAM_RESPONSE,
XtreamCodeActions,
} from 'shared-interfaces';
import { AppConfig } from '../../environments/environment';
import {
@@ -67,7 +66,8 @@ export class ElectronService extends DataService {
}
private setupPortalDebugListener() {
const onPortalDebugEvent = (window.electron as any)?.onPortalDebugEvent as
const onPortalDebugEvent = (window.electron as any)
?.onPortalDebugEvent as
| ((callback: (event: unknown) => void) => void)
| undefined;
@@ -76,7 +76,9 @@ export class ElectronService extends DataService {
}
onPortalDebugEvent((event) => {
logPortalDebugEvent(event as Parameters<typeof logPortalDebugEvent>[0]);
logPortalDebugEvent(
event as Parameters<typeof logPortalDebugEvent>[0]
);
});
}
@@ -228,8 +230,7 @@ export class ElectronService extends DataService {
})
.catch((error: unknown) => {
const statusCode = this.extractHttpStatusCode(error);
let messageKey =
'HOME.URL_UPLOAD.ERROR_FETCH_FAILED';
let messageKey = 'HOME.URL_UPLOAD.ERROR_FETCH_FAILED';
if (statusCode === 403) {
messageKey = 'HOME.URL_UPLOAD.ERROR_403';
} else if (statusCode === 404) {
@@ -257,9 +258,7 @@ export class ElectronService extends DataService {
return error.response.status as number;
}
// Parse status from error message string (IPC serialization)
const msg = String(
(error as { message?: string })?.message ?? error
);
const msg = String((error as { message?: string })?.message ?? error);
const match = msg.match(/status code (\d{3})/);
return match ? parseInt(match[1], 10) : null;
}
@@ -409,9 +408,10 @@ export class ElectronService extends DataService {
if (
maybeError.error &&
typeof maybeError.error === 'object' &&
'message' in (maybeError.error as Record<string, unknown>) &&
typeof (maybeError.error as Record<string, unknown>).message ===
'string'
'message' in
(maybeError.error as Record<string, unknown>) &&
typeof (maybeError.error as Record<string, unknown>)
.message === 'string'
) {
return (maybeError.error as Record<string, string>).message;
}
@@ -24,10 +24,14 @@
<div class="main-content">
<div class="content-header">
<h2>
{{ titleTranslationString() | translate }}
<span class="selected-category-pill">{{ selectedCategoryId() }}</span>
</h2>
<div class="category-meta">
<h2 class="category-title">
{{ titleTranslationString() | translate }}
</h2>
@if (selectedCategoryId()) {
<div class="category-subtitle">{{ selectedCategoryId() }}</div>
}
</div>
@if (showHeaderAction() && !isWorkspaceLayout) {
<button
type="button"
@@ -17,40 +17,51 @@
}
.content-header {
text-transform: capitalize;
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
padding: 8px 12px;
min-height: 48px;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
}
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 10px;
}
.category-meta {
min-width: 0;
flex: 1;
display: flex;
align-items: baseline;
gap: 8px;
}
.selected-category-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 10px 16px -18px var(--app-selection-glow);
}
.category-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Informational badge — primary-tinted pill with definition ring
.category-subtitle {
white-space: nowrap;
flex-shrink: 0;
font-size: 0.70rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: capitalize;
line-height: 1;
color: var(--mat-sys-primary);
background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
padding: 4px 10px;
border-radius: 999px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
0 1px 3px rgba(0, 0, 0, 0.18);
}
.scrollable-content {
+2 -1
View File
@@ -1,6 +1,7 @@
export * from './content-card/content-card.component';
export * from './category-view/category-view.component';
export * from './content-card/content-card.component';
export * from './header/header.component';
export * from './playlist-error-view/playlist-error-view.component';
export * from './portal-collection-shell/portal-collection-shell.component';
export * from './portal-empty-state/portal-empty-state.component';
export * from './stalker-inline-detail/stalker-inline-detail.component';
@@ -47,11 +47,9 @@
</div>
</div>
} @else {
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -1,148 +1,73 @@
@use '@angular/material' as mat;
@use '../../styles/portal-layout' as portal;
:host {
display: flex;
flex-direction: row;
overflow: hidden;
height: 100%;
width: 100%;
}
.sidebar {
width: 400px;
min-width: 250px;
max-width: 600px;
border-right: 1px solid #333;
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
position: relative;
}
.content-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.video-player {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
display: flex;
}
.epg {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.video-player > app-web-player-view {
flex: 1;
min-height: 0;
}
.epg-content {
height: 100%;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
}
@include portal.live-layout;
// ─── Sidebar panel header ─────────────────────────────────────────────────────
.category-content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
min-height: 0;
padding: 11px;
padding: 8px 12px;
min-height: 48px;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
app-region: drag;
// Own buttons (direct children scoped to this component)
button {
@include mat.form-field-density(-5);
app-region: no-drag;
}
// Projected buttons (liveHeaderActions slot) — view encapsulation
// prevents the rule above from reaching them, so ng-deep is required.
::ng-deep button {
app-region: no-drag;
}
}
.category-meta {
min-width: 0;
display: flex;
align-items: baseline;
gap: 10px;
gap: 8px;
flex: 1;
}
// ─── Title + count chip — now aligned with category-content-view ─────────────
.category-title {
margin: 0;
font-size: 1.2rem;
font-weight: 500;
color: inherit;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Informational badge — primary-tinted pill with definition ring
.category-subtitle {
white-space: nowrap;
font-size: 0.76rem;
flex-shrink: 0;
font-size: 0.70rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--app-selection-color);
background: linear-gradient(
145deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 10px 16px -18px var(--app-selection-glow);
letter-spacing: 0.05em;
text-transform: capitalize;
line-height: 1;
color: var(--mat-sys-primary);
background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
padding: 4px 10px;
border-radius: 999px;
flex-shrink: 0;
}
.empty-state-container {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
text-align: center;
color: rgba(255, 255, 255, 0.5);
animation: placeholder-fade-in 220ms ease-out;
}
.empty-state-icon {
font-size: 72px;
width: 72px;
height: 72px;
margin-bottom: 24px;
opacity: 0.2;
animation: placeholder-icon-float 2.8s ease-in-out infinite;
}
.empty-state-title {
font-family: 'DM Sans', sans-serif;
font-weight: 500;
font-size: 1.25rem;
margin: 0;
opacity: 0.5;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
0 1px 3px rgba(0, 0, 0, 0.18);
}
// ─── EPG controls ─────────────────────────────────────────────────────────────
.loading-indicator {
display: flex;
justify-content: center;
@@ -154,34 +79,3 @@
justify-content: center;
padding: 8px 0;
}
@keyframes placeholder-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes placeholder-icon-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
::ng-deep {
.video-player
> app-web-player-view
> app-art-player
> .artplayer-container {
height: 100% !important;
width: 100% !important;
}
}
@@ -1,19 +1,24 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { EpgItem } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
@Component({
selector: 'app-portal-collection-live-shell',
imports: [
EpgViewComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
@@ -0,0 +1,4 @@
<div class="empty-state-container">
<mat-icon class="empty-state-icon">{{ icon() }}</mat-icon>
<h2 class="empty-state-title">{{ message() }}</h2>
</div>
@@ -0,0 +1,55 @@
:host {
display: flex;
flex: 1;
min-height: 0;
}
.empty-state-container {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
text-align: center;
color: var(--mat-sys-on-surface-variant);
animation: portal-empty-state-fade-in 220ms ease-out;
}
.empty-state-icon {
font-size: 72px;
width: 72px;
height: 72px;
margin-bottom: 24px;
opacity: 0.2;
animation: portal-empty-state-float 2.8s ease-in-out infinite;
}
.empty-state-title {
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.01em;
margin: 0;
opacity: 0.5;
}
@keyframes portal-empty-state-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes portal-empty-state-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'app-portal-empty-state',
templateUrl: './portal-empty-state.component.html',
styleUrl: './portal-empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon],
})
export class PortalEmptyStateComponent {
readonly icon = input<string>('live_tv');
readonly message = input.required<string>();
}
@@ -51,8 +51,9 @@
[logo]="item.logo"
[selected]="isSelectedChannel(item)"
[epgProgram]="
epgPreviewPrograms.get(normalizeStalkerEntityId(item.id)) ??
null
epgPreviewPrograms.get(
normalizeStalkerEntityId(item.id)
) ?? null
"
[progressPercentage]="
currentProgramsProgress.get(
@@ -62,7 +63,8 @@
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="
favorites.get(normalizeStalkerEntityId(item.id)) ?? false
favorites.get(normalizeStalkerEntityId(item.id)) ??
false
"
(clicked)="playChannel(item)"
(favoriteToggled)="toggleFavorite(item)"
@@ -87,15 +89,20 @@
/>
} @else if (isCategoryFailed()) {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate"
[title]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.TITLE' | translate
"
[description]="
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION' | translate
'PORTALS.ERROR_VIEW.UNKNOWN_ERROR.DESCRIPTION'
| translate
"
/>
} @else {
<app-category-view
[items]="categories()"
[selectedCategoryId]="$any(stalkerStore.selectedCategoryId())"
[selectedCategoryId]="
$any(stalkerStore.selectedCategoryId())
"
(categoryClicked)="selectCategory($event)"
/>
}
@@ -128,11 +135,9 @@
</div>
</div>
} @else {
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -1,100 +1,7 @@
@use '@angular/material' as mat;
@use '../../shared/styles/portal-layout' as portal;
:host {
display: flex;
flex-direction: row;
overflow: hidden;
height: 100%;
width: 100%;
}
.sidebar {
width: 400px;
min-width: 250px;
max-width: 600px;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
position: relative;
container-type: inline-size;
app-category-view {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
margin-right: 2px;
}
}
.content-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.video-player {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
display: flex;
}
.epg {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.video-player > app-web-player-view {
flex: 1;
min-height: 0;
}
.epg-content {
height: 100%;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
}
.empty-state-container {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
text-align: center;
color: rgba(255, 255, 255, 0.5);
animation: placeholder-fade-in 220ms ease-out;
}
.empty-state-icon {
font-size: 72px;
width: 72px;
height: 72px;
margin-bottom: 24px;
opacity: 0.2;
animation: placeholder-icon-float 2.8s ease-in-out infinite;
}
.empty-state-title {
font-family: 'DM Sans', sans-serif;
font-weight: 500;
font-size: 1.25rem;
margin: 0;
opacity: 0.5;
}
@include portal.live-layout;
.search-panel {
padding: 8px 12px;
@@ -142,39 +49,8 @@
padding: 8px 0;
}
@keyframes placeholder-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes placeholder-icon-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@container (max-width: 350px) {
.channels-list {
padding-inline: 6px;
}
}
::ng-deep {
.video-player
> app-web-player-view
> app-art-player
> .artplayer-container {
height: 100% !important;
width: 100% !important;
}
}
@@ -25,35 +25,28 @@ import {
PlaylistSwitcherComponent,
ResizableDirective,
} from 'components';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistsService, StalkerSessionService } from 'services';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { SettingsStore } from '../../services/settings-store.service';
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
import { PortalEmptyStateComponent } from '../../shared/components/portal-empty-state/portal-empty-state.component';
import { isWorkspaceLayoutRoute } from '../../shared/navigation/portal-route.utils';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
import { StalkerStore } from '../stalker.store';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { createLogger } from '../../shared/utils/logger';
import {
StalkerCategoryItem,
StalkerFavoriteItem,
StalkerItvChannel,
} from '../models';
import {
buildStalkerExternalPlaybackHeaders,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
STALKER_MAG_USER_AGENT,
} from '../stalker-live-playback.utils';
import {
normalizeStalkerEntityId,
} from '../stalker-vod.utils';
import { normalizeStalkerEntityId } from '../stalker-vod.utils';
import { StalkerStore } from '../stalker.store';
@Component({
selector: 'app-stalker-live-stream-layout',
@@ -76,6 +69,7 @@ import {
NgxSkeletonLoaderModule,
PlaylistErrorViewComponent,
PlaylistSwitcherComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
@@ -147,7 +141,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
.subscribe((favs) => {
favs.forEach((fav: StalkerFavoriteItem) => {
if (fav.id !== undefined) {
this.favorites.set(normalizeStalkerEntityId(fav.id), true);
this.favorites.set(
normalizeStalkerEntityId(fav.id),
true
);
}
});
});
@@ -181,7 +178,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// Debounced server-side search
effect(() => {
const search = this.searchString();
if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
if (this.searchDebounceTimer)
clearTimeout(this.searchDebounceTimer);
this.searchDebounceTimer = setTimeout(() => {
this.stalkerStore.setItvChannels([]);
this.stalkerStore.setSearchPhrase(search);
@@ -225,11 +223,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
});
if (window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange((data: {
direction: 'up' | 'down';
}) => {
this.handleRemoteChannelChange(data.direction);
});
const unsubscribe = window.electron.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
@@ -268,8 +266,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
isSelectedChannel(item: StalkerItvChannel): boolean {
return (
this.selectedChannelId() ===
this.normalizeStalkerEntityId(item.id)
this.selectedChannelId() === this.normalizeStalkerEntityId(item.id)
);
}
@@ -394,10 +391,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private async loadSingleEpgPreview(channelId: number | string) {
try {
const items = await this.stalkerStore.fetchChannelEpg(
channelId,
1
);
const items = await this.stalkerStore.fetchChannelEpg(channelId, 1);
if (items.length > 0) {
const program = items[0];
const id = normalizeStalkerEntityId(channelId);
@@ -411,8 +405,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const end = parseInt(program.stop_timestamp, 10);
if (start && end && now >= start && now <= end) {
const progress =
((now - start) / (end - start)) * 100;
const progress = ((now - start) / (end - start)) * 100;
this.currentProgramsProgress.set(id, progress);
} else {
this.currentProgramsProgress.delete(id);
@@ -516,7 +509,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return;
}
const channel = getChannelItemByNumber(this.itvChannels(), command.number);
const channel = getChannelItemByNumber(
this.itvChannels(),
command.number
);
if (!channel) {
return;
}
@@ -1,8 +1,11 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { withMethods, signalStoreFeature } from '@ngrx/signals';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
/**
* Favorites concern methods.
@@ -14,23 +17,30 @@ export function withStalkerFavorites() {
store,
playlistService = inject(PlaylistsService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
return {
addToFavorites(item: any, onDone?: () => void) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.addPortalFavorite(
storeAny.currentPlaylist()?._id,
{
...item,
category_id:
storeAny.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
}
)
.subscribe(() => {
.addPortalFavorite(portalId, {
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
favorites:
updatedPlaylist?.favorites,
} as PlaylistMeta,
})
);
snackBar.open(
translate.instant(
'PORTALS.ADDED_TO_FAVORITES'
@@ -47,12 +57,19 @@ export function withStalkerFavorites() {
favoriteId: string,
onDone?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.removeFromPortalFavorites(
storeAny.currentPlaylist()?._id,
favoriteId
)
.subscribe(() => {
.removeFromPortalFavorites(portalId, favoriteId)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
favorites:
updatedPlaylist?.favorites,
} as PlaylistMeta,
})
);
snackBar.open(
translate.instant(
'PORTALS.REMOVED_FROM_FAVORITES'
@@ -1,23 +1,26 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import {
Playlist,
PlaylistMeta,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { PlayerService } from '../../../services/player.service';
import { createLogger } from '../../../shared/utils/logger';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
buildStalkerExternalPlaybackHeaders,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
STALKER_MAG_USER_AGENT,
} from '../../stalker-live-playback.utils';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
@@ -63,7 +66,8 @@ export function withStalkerPlayer() {
playerService = inject(PlayerService),
stalkerSession = inject(StalkerSessionService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeState = store as unknown as StalkerPlayerStoreLike;
const fetchLinkToPlayInternal = async (
@@ -81,9 +85,7 @@ export function withStalkerPlayer() {
// "ffmpeg http://...", "ffrt http://...", etc.
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed
.slice(splitAt + 1)
.trim();
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://') ||
@@ -418,7 +420,17 @@ export function withStalkerPlayer() {
};
playlistService
.addPortalRecentlyViewed(playlistId, recentlyViewedItem)
.subscribe();
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
);
});
};
return {
@@ -548,15 +560,14 @@ export function withStalkerPlayer() {
startTime?: number
) {
try {
const playback =
await resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
const playback = await resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
void playerService.openResolvedPlayback(
playback,
@@ -1,6 +1,9 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { createLogger } from '../../../shared/utils/logger';
/**
@@ -9,41 +12,64 @@ import { createLogger } from '../../../shared/utils/logger';
export function withStalkerRecent() {
const logger = createLogger('withStalkerRecent');
return signalStoreFeature(
withMethods((store, playlistService = inject(PlaylistsService)) => {
const storeAny = store as any;
withMethods(
(
store,
playlistService = inject(PlaylistsService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
return {
addToRecentlyViewed(item: any) {
playlistService
.addPortalRecentlyViewed(
storeAny.currentPlaylist()?._id,
{
return {
addToRecentlyViewed(item: any) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.addPortalRecentlyViewed(portalId, {
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
}
)
.subscribe();
},
removeFromRecentlyViewed(
itemId: string | number,
onComplete?: () => void
) {
playlistService
.removeFromPortalRecentlyViewed(
storeAny.currentPlaylist()?._id,
itemId
)
.subscribe({
next: () => onComplete?.(),
error: (error) =>
logger.error(
'Failed to remove item from recently viewed',
error
),
});
},
};
})
})
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
);
});
},
removeFromRecentlyViewed(
itemId: string | number,
onComplete?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.removeFromPortalRecentlyViewed(portalId, itemId)
.subscribe({
next: (updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
);
onComplete?.();
},
error: (error) =>
logger.error(
'Failed to remove item from recently viewed',
error
),
});
},
};
}
)
);
}
@@ -105,12 +105,13 @@
<app-add-playlist-menu
(playlistTypeSelected)="openAddPlaylistDialog($event)"
/>
<span class="header-actions-divider" aria-hidden="true"></span>
<a
class="header-shortcut"
mat-icon-button
routerLink="/workspace/global-favorites"
[class.is-active]="isGlobalFavoritesRoute()"
matTooltip="Global favorites (all playlists)"
matTooltip="Global favorites"
aria-label="Open global favorites"
>
<mat-icon>star</mat-icon>
@@ -122,21 +123,24 @@
mat-icon-button
[class.is-active]="isDownloadsView()"
(click)="openDownloadsShortcut()"
matTooltip="Downloads (all playlists)"
matTooltip="Downloads"
aria-label="Open downloads"
>
<mat-icon>download</mat-icon>
</button>
}
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="contextActionsMenu"
matTooltip="Context actions (this section)"
aria-label="Open context actions"
>
<mat-icon>more_horiz</mat-icon>
</button>
@if (hasContextActions()) {
<span class="header-actions-divider" aria-hidden="true"></span>
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="contextActionsMenu"
matTooltip="Section actions"
aria-label="Open context actions"
>
<mat-icon>more_horiz</mat-icon>
</button>
}
</div>
<mat-menu #contextActionsMenu="matMenu">
@@ -137,7 +137,7 @@
// ─── Rail Divider ─────────────────────────────────────────────────────────────
.rail-divider {
width: 24px;
width: 44px;
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 6px 0;
@@ -155,7 +155,7 @@
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
padding: 0 24px;
height: 56px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
background: var(--app-header-bg, var(--mat-sys-surface-container-low));
@@ -203,7 +203,7 @@
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px rgba(var(--mat-sys-primary), 0.12);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--mat-sys-primary) 12%, transparent);
}
mat-icon {
@@ -260,7 +260,7 @@
.header-actions {
display: flex;
align-items: center;
gap: 2px;
gap: 4px;
app-region: no-drag;
button,
@@ -290,6 +290,15 @@
}
}
.header-actions-divider {
width: 1px;
height: 20px;
background: var(--mat-sys-outline-variant);
margin: 0 4px;
flex-shrink: 0;
opacity: 0.6;
}
// ─── Workspace Body ───────────────────────────────────────────────────────────
.workspace-body {
grid-column: 2;
@@ -1,12 +1,12 @@
import { inject, Injectable } from '@angular/core';
import { DatabaseService, PlaybackPositionService } from 'services';
import { XtreamCategory } from 'shared-interfaces';
import {
XtreamCategory,
XtreamLiveStream,
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import { CategoryType, StreamType, XtreamApiService, XtreamCredentials } from '../services/xtream-api.service';
CategoryType,
StreamType,
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import {
DbCategoryType,
IXtreamDataSource,
@@ -81,15 +81,14 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
): Promise<XtreamCategoryFromDb[]> {
const dbType = mapCategoryTypeToDbType(type);
// Check if we have cached data
const exists = await this.dbService.hasXtreamCategories(
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamCategories(
playlistId,
dbType
);
if (exists) {
// Return from cache
return this.dbService.getXtreamCategories(playlistId, dbType);
if (cached.length > 0) {
return cached;
}
// Fetch from API and cache
@@ -100,8 +99,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
if (remoteData && Array.isArray(remoteData) && remoteData.length > 0) {
// Check if there are saved hidden categories to restore
const hiddenCategoryXtreamIds =
this.getHiddenCategoryXtreamIds(playlistId, dbType);
const hiddenCategoryXtreamIds = this.getHiddenCategoryXtreamIds(
playlistId,
dbType
);
await this.dbService.saveXtreamCategories(
playlistId,
@@ -189,12 +190,11 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onProgress?: (count: number) => void,
onTotal?: (total: number) => void
): Promise<XtreamContentItem[]> {
// Check if we have cached data
const exists = await this.dbService.hasXtreamContent(playlistId, type);
if (exists) {
// Return from cache
return this.dbService.getXtreamContent(playlistId, type);
// Fetch from DB directly — avoids a separate 'has' round-trip.
// An empty result means the cache is cold; proceed to fetch from API.
const cached = await this.dbService.getXtreamContent(playlistId, type);
if (cached.length > 0) {
return cached;
}
// Fetch from API
@@ -243,7 +243,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
return this.dbService.searchXtreamContent(playlistId, searchTerm, types, excludeHidden);
return this.dbService.searchXtreamContent(
playlistId,
searchTerm,
types,
excludeHidden
);
}
// =========================================================================
@@ -304,10 +309,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
// Playback Position Operations
// =========================================================================
async savePlaybackPosition(
playlistId: string,
data: any
): Promise<void> {
async savePlaybackPosition(playlistId: string, data: any): Promise<void> {
await this.playbackService.savePlaybackPosition(playlistId, data);
}
@@ -363,6 +365,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
// Cleanup Operations
// =========================================================================
/**
* No-op for Electron: DB-backed storage has no in-memory session cache to clear.
*/
clearSessionCache(_playlistId: string): void {
// Electron uses the DB as its cache layer; no in-memory state to evict.
}
async clearPlaylistContent(playlistId: string): Promise<{
favoritedXtreamIds: number[];
recentlyViewedXtreamIds: { xtreamId: number; viewedAt: string }[];
@@ -5,7 +5,13 @@ import {
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import { CategoryType, StreamType, XtreamApiService, XtreamCredentials } from '../services/xtream-api.service';
import { createLogger } from '../../shared/utils/logger';
import {
CategoryType,
StreamType,
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import {
DbCategoryType,
IXtreamDataSource,
@@ -14,7 +20,6 @@ import {
XtreamContentItem,
XtreamPlaylistData,
} from './xtream-data-source.interface';
import { createLogger } from '../../shared/utils/logger';
/**
* LocalStorage keys for PWA persistence
@@ -305,9 +310,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
}
private saveFavoritesToStorage(
favorites: Record<string, number[]>
): void {
private saveFavoritesToStorage(favorites: Record<string, number[]>): void {
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
}
@@ -371,7 +374,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return playlistPositions.filter(
(p: any) =>
p.contentType === 'episode' && p.seriesXtreamId === seriesXtreamId
p.contentType === 'episode' &&
p.seriesXtreamId === seriesXtreamId
);
}
@@ -385,7 +389,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Sort by updatedAt descending
playlistPositions.sort(
(a: any, b: any) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime()
);
return limit ? playlistPositions.slice(0, limit) : playlistPositions;
@@ -618,6 +623,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
// Cache Management
// =========================================================================
/**
* Clear in-memory cache entries for a specific playlist.
* Called by the store when switching playlists to prevent stale data bleed.
*/
clearSessionCache(playlistId: string): void {
this.clearCacheForPlaylist(playlistId);
}
private clearCacheForPlaylist(playlistId: string): void {
const keysToDelete: string[] = [];
@@ -6,10 +6,14 @@ import {
XtreamSerieItem,
XtreamVodStream,
} from 'shared-interfaces';
import {
CategoryType,
StreamType,
XtreamCredentials,
} from '../services/xtream-api.service';
// Re-export for backward compatibility
export { PlaybackPositionData };
import { XtreamCredentials, CategoryType, StreamType } from '../services/xtream-api.service';
/**
* Playlist representation in the data source
@@ -101,7 +105,9 @@ export function mapCategoryTypeToDbType(type: CategoryType): DbCategoryType {
/**
* Maps StreamType to DbCategoryType for content storage
*/
export function mapStreamTypeToDbType(type: StreamType): 'live' | 'movie' | 'series' {
export function mapStreamTypeToDbType(
type: StreamType
): 'live' | 'movie' | 'series' {
return type;
}
@@ -149,10 +155,7 @@ export interface IXtreamDataSource {
/**
* Check if categories exist for a playlist and type
*/
hasCategories(
playlistId: string,
type: DbCategoryType
): Promise<boolean>;
hasCategories(playlistId: string, type: DbCategoryType): Promise<boolean>;
/**
* Get categories for a playlist and type
@@ -212,7 +215,12 @@ export interface IXtreamDataSource {
type: StreamType,
onProgress?: ProgressCallback,
onTotal?: (total: number) => void
): Promise<XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[] | XtreamContentItem[]>;
): Promise<
| XtreamLiveStream[]
| XtreamVodStream[]
| XtreamSerieItem[]
| XtreamContentItem[]
>;
/**
* Save content in bulk
@@ -338,7 +346,9 @@ export interface IXtreamDataSource {
/**
* Get all playback positions for a playlist (for grid view)
*/
getAllPlaybackPositions(playlistId: string): Promise<PlaybackPositionData[]>;
getAllPlaybackPositions(
playlistId: string
): Promise<PlaybackPositionData[]>;
/**
* Clear playback position (mark as unwatched)
@@ -353,6 +363,13 @@ export interface IXtreamDataSource {
// Cleanup Operations
// =========================================================================
/**
* Clear any in-memory session cache for a playlist.
* Called when the store resets for a playlist switch so stale data
* cannot bleed into the new session (relevant for the PWA implementation).
*/
clearSessionCache(playlistId: string): void;
/**
* Clear all content and categories for a playlist (for refresh)
* Returns user data (favorites, recently viewed) for restoration
@@ -1,11 +1,12 @@
import {
Component,
effect,
OnInit,
computed,
effect,
inject,
OnInit,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -15,24 +16,22 @@ import { TranslatePipe } from '@ngx-translate/core';
import { selectActivePlaylist } from 'm3u-state';
import { BehaviorSubject, switchMap } from 'rxjs';
import { PlayerService } from '../../services/player.service';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { PortalCollectionLiveShellComponent } from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component';
import {
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
import {
PortalCollectionLiveShellComponent,
} from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component';
import { queryParamSignal } from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { FavoriteItem } from '../services/favorite-item.interface';
import { FavoritesService } from '../services/favorites.service';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { XtreamStore } from '../stores/xtream.store';
const XTREAM_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
@@ -49,6 +48,7 @@ const XTREAM_FAVORITES_LIVE_SORT_STORAGE_KEY =
@Component({
selector: 'app-favorites',
imports: [
MatButtonModule,
MatIconModule,
MatMenuModule,
MatTooltipModule,
@@ -120,7 +120,9 @@ export class FavoritesComponent implements OnInit {
return `${item?.title ?? ''}`.toLowerCase().includes(term);
})
);
readonly isLiveCategory = computed(() => this.selectedCategoryId() === 'live');
readonly isLiveCategory = computed(
() => this.selectedCategoryId() === 'live'
);
readonly isEmbeddedPlayer = computed(() =>
this.playerService.isEmbeddedPlayer()
);
@@ -15,11 +15,16 @@
<!-- <button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button> -->
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }} ({{
selectedCategoryInfo()?.count
}})
</h2>
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
</div>
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
@@ -111,12 +116,10 @@
</div>
</div>
} @else {
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
<!-- <app-mpv-player-bar /> -->
</div>
@@ -1,151 +1,70 @@
@use '@angular/material' as mat;
@use '../../shared/styles/portal-layout' as portal;
:host {
display: flex;
flex-direction: row;
overflow: hidden;
height: 100%;
width: 100%;
}
.sidebar {
// Width controlled by resizable directive
width: 400px; // Default, will be overridden by directive
min-width: 250px;
max-width: 600px;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
position: relative;
app-portal-channels-list {
flex: 1;
overflow: hidden;
width: 100%;
}
app-category-view {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
margin-right: 2px;
}
}
.content-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.video-player {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
display: flex;
}
.epg {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.video-player > app-web-player-view {
flex: 1;
min-height: 0;
}
.epg-content {
height: 100%;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
}
@include portal.live-layout;
// ─── Sidebar-header ────────────────────────────────────────────────────────
.sidebar-header {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 12px;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
gap: 4px;
app-region: drag;
flex-shrink: 0;
button {
@include mat.form-field-density(-5);
app-region: no-drag;
}
.switcher {
flex: 1;
min-width: 0;
}
.manage-categories-btn {
margin-left: auto;
}
}
// ─── Category heading + count pill ─────────────────────────────────────────
.category-heading {
display: flex;
align-items: baseline;
gap: 8px;
flex: 1;
min-width: 0;
}
.category-title {
flex: 1;
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty-state-container {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
text-align: center;
color: rgba(255, 255, 255, 0.5);
animation: placeholder-fade-in 220ms ease-out;
}
.empty-state-icon {
font-size: 72px;
width: 72px;
height: 72px;
margin-bottom: 24px;
opacity: 0.2;
animation: placeholder-icon-float 2.8s ease-in-out infinite;
}
.empty-state-title {
font-family: 'DM Sans', sans-serif;
.category-count {
flex-shrink: 0;
font-size: 0.72rem;
font-weight: 500;
font-size: 1.25rem;
margin: 0;
opacity: 0.5;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface-container);
padding: 2px 8px;
border-radius: 999px;
}
@keyframes placeholder-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes placeholder-icon-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
::ng-deep {
.video-player
> app-web-player-view
> app-art-player
> .artplayer-container {
height: 100% !important;
width: 100% !important;
// ─── Side-panel channel list ───────────────────────────────────────────────
.sidebar {
app-portal-channels-list {
flex: 1;
overflow: hidden;
width: 100%;
}
}
@@ -3,8 +3,8 @@ import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
effect,
ElementRef,
inject,
Injector,
OnDestroy,
@@ -28,6 +28,7 @@ import { XtreamCategory } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { SettingsStore } from '../../services/settings-store.service';
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
import { PortalEmptyStateComponent } from '../../shared/components/portal-empty-state/portal-empty-state.component';
import { isWorkspaceLayoutRoute } from '../../shared/navigation/portal-route.utils';
import {
getAdjacentChannelItem,
@@ -65,6 +66,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
/* MpvPlayerBarComponent, */
PlaylistSwitcherComponent,
PortalChannelsListComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
@@ -137,7 +139,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
return;
}
const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[];
const channels =
this.xtreamStore.selectItemsFromSelectedCategory() as any[];
if (!Array.isArray(channels) || channels.length === 0) {
return;
}
@@ -162,7 +165,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
const selectedContentType = this.xtreamStore.selectedContentType();
const selectedItem = this.xtreamStore.selectedItem();
const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[];
const channels =
this.xtreamStore.selectItemsFromSelectedCategory() as any[];
const epgItems = this.xtreamStore.epgItems();
if (selectedContentType !== 'live' || !selectedItem?.xtream_id) {
@@ -195,11 +199,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
ngOnInit() {
if (window.electron?.onChannelChange) {
const unsubscribe = window.electron.onChannelChange((data: {
direction: 'up' | 'down';
}) => {
this.handleRemoteChannelChange(data.direction);
});
const unsubscribe = window.electron.onChannelChange(
(data: { direction: 'up' | 'down' }) => {
this.handleRemoteChannelChange(data.direction);
}
);
if (typeof unsubscribe === 'function') {
this.unsubscribeRemoteChannelChange = unsubscribe;
}
@@ -319,7 +323,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
return;
}
const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[];
const channels =
this.xtreamStore.selectItemsFromSelectedCategory() as any[];
const nextItem = getAdjacentChannelItem(
channels,
activeItem.xtream_id,
@@ -346,7 +351,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
return;
}
const channels = this.xtreamStore.selectItemsFromSelectedCategory() as any[];
const channels =
this.xtreamStore.selectItemsFromSelectedCategory() as any[];
const channel = getChannelItemByNumber(channels, command.number);
if (!channel) {
return;
@@ -357,7 +363,10 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private clearAutoOpenHistoryState(): void {
try {
const state = (window.history.state ?? {}) as Record<string, unknown>;
const state = (window.history.state ?? {}) as Record<
string,
unknown
>;
if (!('openXtreamLiveItemId' in state)) {
return;
}
@@ -4,6 +4,7 @@
width: 100%;
overflow: hidden;
container-type: inline-size;
border-right: 1px solid var(--app-separator);
}
.channels-list {
@@ -34,6 +35,9 @@
box-sizing: border-box;
display: flex;
align-items: center;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
app-region: drag;
}
.search-field {
@@ -33,8 +33,8 @@
padding: 0 12px;
height: 42px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--app-separator);
background: var(--mat-sys-surface-container-low);
mat-icon {
opacity: 0.6;
@@ -76,9 +76,10 @@
.playlist-section {
.section-title {
margin: 0 0 1.5rem;
font-size: 1.2rem;
font-weight: normal;
color: var(--text-color-secondary);
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
padding: 0 8px;
}
}
@@ -267,11 +267,11 @@ export function withContent() {
});
try {
// Fetch categories first
await this.fetchAllCategories();
// Then fetch content (with progress tracking)
await this.fetchAllContent();
// Fetch categories and content in parallel for faster initial load
await Promise.all([
this.fetchAllCategories(),
this.fetchAllContent(),
]);
// Restore user data if needed
const restoreKey = `xtream-restore-${ctx.playlistId}`;
@@ -8,6 +8,15 @@ import {
} from '@ngrx/signals';
import { ContentType } from '../../xtream-state';
/**
* Module-level collator — allocating Intl.Collator is expensive;
* one shared instance avoids repeated allocation on every sort call.
*/
const COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export type XtreamCategorySortMode =
| 'date-desc'
| 'date-asc'
@@ -57,10 +66,13 @@ export function withSelection() {
withState<SelectionState>(initialSelectionState),
withComputed((store) => {
const getItemDate = (item: any, categoryType: ContentType): number => {
const getItemDate = (
item: any,
categoryType: ContentType
): number => {
const value =
categoryType === 'series'
? item.last_modified ?? item.added
? (item.last_modified ?? item.added)
: item.added;
return parseInt(value ?? '', 10) || 0;
};
@@ -70,27 +82,31 @@ export function withSelection() {
sortMode: XtreamCategorySortMode,
categoryType: ContentType
): any[] => {
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
return [...items].sort((a: any, b: any) => {
if (sortMode === 'date-desc') {
return getItemDate(b, categoryType) - getItemDate(a, categoryType);
return (
getItemDate(b, categoryType) -
getItemDate(a, categoryType)
);
}
if (sortMode === 'date-asc') {
return getItemDate(a, categoryType) - getItemDate(b, categoryType);
return (
getItemDate(a, categoryType) -
getItemDate(b, categoryType)
);
}
const titleA = a.title ?? a.name ?? '';
const titleB = b.title ?? b.name ?? '';
const byName = collator.compare(titleA, titleB);
const byName = COLLATOR.compare(titleA, titleB);
return sortMode === 'name-asc' ? byName : -byName;
});
};
const filterBySearchTerm = (items: any[], searchTerm: string): any[] => {
const filterBySearchTerm = (
items: any[],
searchTerm: string
): any[] => {
const normalized = searchTerm.trim().toLocaleLowerCase();
if (!normalized) {
return items;
@@ -116,6 +132,65 @@ export function withSelection() {
return sortByMode(content, 'date-desc', categoryType);
});
// ---------------------------------------------------------------------------
// Per-type category item-count maps.
// Each computed only recomputes when ITS streams array changes —
// switching content tabs no longer triggers an O(n) full scan.
// ---------------------------------------------------------------------------
const buildCountMap = (streams: any[]): Map<number, number> => {
const countMap = new Map<number, number>();
for (const item of streams) {
const catId = Number(item.category_id);
if (!isNaN(catId)) {
countMap.set(catId, (countMap.get(catId) || 0) + 1);
}
}
return countMap;
};
const liveItemCounts = computed(() =>
buildCountMap((store as any).liveStreams?.() || [])
);
const vodItemCounts = computed(() =>
buildCountMap((store as any).vodStreams?.() || [])
);
const seriesItemCounts = computed(() =>
buildCountMap((store as any).serialStreams?.() || [])
);
// ---------------------------------------------------------------------------
// Stable filter + sort intermediate.
// Depends on category / search / sort — but NOT on page or limit.
// This prevents re-sorting the full array on every page-navigation.
// ---------------------------------------------------------------------------
const filteredAndSortedContent = computed(() => {
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
const sortMode = store.contentSortMode();
const searchTerm = store.categorySearchTerm();
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
if (!categoryId) {
return sortedContent();
}
let filtered = content.filter(
(item: any) => Number(item.category_id) === categoryId
);
if (categoryType === 'vod' || categoryType === 'series') {
filtered = filterBySearchTerm(filtered, searchTerm);
filtered = sortByMode(filtered, sortMode, categoryType);
}
return filtered;
});
return {
/**
* Get the selected category from the parent store's categories
@@ -172,118 +247,34 @@ export function withSelection() {
}),
/**
* Get paginated content for the selected category
* Get paginated content for the selected category.
* Slices from the stable `filteredAndSortedContent` intermediate so
* page navigation never triggers a full re-sort of the array.
*/
getPaginatedContent: computed(() => {
const startIndex = store.page() * store.limit();
const endIndex = startIndex + store.limit();
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
const sortMode = store.contentSortMode();
const searchTerm = store.categorySearchTerm();
// Access parent store content (from withContent)
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
let filteredContent = content;
if (categoryId) {
filteredContent = content.filter(
(item: any) =>
Number(item.category_id) === categoryId
);
if (categoryType === 'vod' || categoryType === 'series') {
filteredContent = filterBySearchTerm(
filteredContent,
searchTerm
);
filteredContent = sortByMode(
filteredContent,
sortMode,
categoryType
);
}
} else {
filteredContent = sortedContent();
}
return filteredContent.slice(startIndex, endIndex);
}),
/**
* Get all items from the selected category (without pagination)
*/
selectItemsFromSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
const sortMode = store.contentSortMode();
const searchTerm = store.categorySearchTerm();
// Access parent store content (from withContent)
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
if (!categoryId) {
return sortedContent();
}
const filteredContent = content.filter(
(item: any) => Number(item.category_id) === categoryId
const start = store.page() * store.limit();
return filteredAndSortedContent().slice(
start,
start + store.limit()
);
if (categoryType === 'vod' || categoryType === 'series') {
return sortByMode(
filterBySearchTerm(filteredContent, searchTerm),
sortMode,
categoryType
);
}
return filteredContent;
}),
/**
* Get total pages for the selected category
* Get all items from the selected category (without pagination).
* Reuses the `filteredAndSortedContent` intermediate to avoid
* duplicating the filter+sort work already done for pagination.
*/
getTotalPages: computed(() => {
const categoryId = store.selectedCategoryId();
const categoryType = store.selectedContentType();
const searchTerm = store.categorySearchTerm();
selectItemsFromSelectedCategory: computed(() =>
filteredAndSortedContent()
),
// Access parent store content (from withContent)
const storeAny = store as any;
const content =
categoryType === 'live'
? storeAny.liveStreams?.() || []
: categoryType === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
let totalItems = 0;
if (categoryId) {
let filtered = content.filter(
(item: any) =>
Number(item.category_id) === categoryId
);
if (categoryType === 'vod' || categoryType === 'series') {
filtered = filterBySearchTerm(filtered, searchTerm);
}
totalItems = filtered.length;
} else {
totalItems = content.length;
}
return Math.ceil(totalItems / store.limit());
}),
/**
* Get total pages for the selected category.
* Derives length from the shared `filteredAndSortedContent` intermediate.
*/
getTotalPages: computed(() =>
Math.ceil(filteredAndSortedContent().length / store.limit())
),
/**
* Check if paginated content is loading
@@ -295,28 +286,17 @@ export function withSelection() {
}),
/**
* Memoized category item counts map
* Memoized category item counts map.
* Selects from per-type pre-computed maps so switching content tabs
* is O(1) — no full array scan on every tab switch.
*/
getCategoryItemCounts: computed(() => {
const type = store.selectedContentType();
// Access parent store content (from withContent)
const storeAny = store as any;
const streams =
type === 'live'
? storeAny.liveStreams?.() || []
: type === 'vod'
? storeAny.vodStreams?.() || []
: storeAny.serialStreams?.() || [];
const countMap = new Map<number, number>();
for (const item of streams) {
const catId = Number(item.category_id);
if (!isNaN(catId)) {
countMap.set(catId, (countMap.get(catId) || 0) + 1);
}
}
return countMap;
return type === 'live'
? liveItemCounts()
: type === 'vod'
? vodItemCounts()
: seriesItemCounts();
}),
/**
@@ -1,34 +1,28 @@
import { computed, inject } from '@angular/core';
import {
signalStore,
withComputed,
withMethods,
} from '@ngrx/signals';
import { signalStore, withComputed, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { selectActivePlaylist } from 'm3u-state';
import {
XtreamSerieDetails,
XtreamVodDetails,
} from 'shared-interfaces';
import { XtreamSerieDetails, XtreamVodDetails } from 'shared-interfaces';
// Import existing features that are already separate
import { withFavorites } from '../with-favorites.feature';
import { withRecentItems } from '../with-recent-items';
// Import service
import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface';
import { XtreamApiService } from '../services/xtream-api.service';
// Import new feature stores
import {
withPortal,
withContent,
withSelection,
withSearch,
withEpg,
withPlayer,
withPlaybackPositions,
} from './features';
import { createLogger } from '../../shared/utils/logger';
import {
withContent,
withEpg,
withPlaybackPositions,
withPlayer,
withPortal,
withSearch,
withSelection,
} from './features';
/**
* XtreamStore - Facade composing all feature stores.
@@ -81,15 +75,26 @@ export const XtreamStore = signalStore(
withMethods((store) => {
const xtreamApiService = inject(XtreamApiService);
const ngrxStore = inject(Store);
const dataSource = inject(XTREAM_DATA_SOURCE);
const logger = createLogger('XtreamStore');
const searchContent = (store as any)
.searchContent as (term: string, types: string[], excludeHidden?: boolean) => Promise<unknown>;
const searchContent = (store as any).searchContent as (
term: string,
types: string[],
excludeHidden?: boolean
) => Promise<unknown>;
return {
/**
* Full store reset for switching between playlists
*/
resetStore(newPlaylistId?: string): void {
// Clear the session cache for the playlist we're leaving so
// stale data cannot bleed into the new playlist (PWA path).
const leavingPlaylistId = store.playlistId();
if (leavingPlaylistId) {
dataSource.clearSessionCache(leavingPlaylistId);
}
store.resetPortal();
store.resetContent();
store.resetSelection();
@@ -126,64 +131,84 @@ export const XtreamStore = signalStore(
* Fetch VOD details with metadata
* Accepts object format for backward compatibility with rxMethod callers
*/
fetchVodDetailsWithMetadata(
params: { vodId: string; categoryId: number }
): void {
fetchVodDetailsWithMetadata(params: {
vodId: string;
categoryId: number;
}): void {
const playlist = ngrxStore.selectSignal(selectActivePlaylist)();
if (!playlist) return;
store.setIsLoadingDetails(true);
store.setDetailsError(null);
xtreamApiService.getVodInfo({
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
}, params.vodId).then((vodDetails: XtreamVodDetails) => {
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...vodDetails,
stream_id: params.vodId,
xtreamApiService
.getVodInfo(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
params.vodId
)
.then((vodDetails: XtreamVodDetails) => {
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...vodDetails,
stream_id: params.vodId,
});
})
.catch((error: unknown) => {
logger.error('Error fetching VOD details', error);
store.setDetailsError(
error instanceof Error
? error.message
: 'Unknown error'
);
})
.finally(() => {
store.setIsLoadingDetails(false);
});
}).catch((error: unknown) => {
logger.error('Error fetching VOD details', error);
store.setDetailsError(
error instanceof Error ? error.message : 'Unknown error'
);
}).finally(() => {
store.setIsLoadingDetails(false);
});
},
/**
* Fetch series details with metadata
* Accepts object format for backward compatibility with rxMethod callers
*/
fetchSerialDetailsWithMetadata(
params: { serialId: string; categoryId: number }
): void {
fetchSerialDetailsWithMetadata(params: {
serialId: string;
categoryId: number;
}): void {
const playlist = ngrxStore.selectSignal(selectActivePlaylist)();
if (!playlist) return;
store.setIsLoadingDetails(true);
store.setDetailsError(null);
xtreamApiService.getSeriesInfo({
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
}, params.serialId).then((serialDetails: XtreamSerieDetails) => {
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...serialDetails,
series_id: params.serialId,
xtreamApiService
.getSeriesInfo(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
params.serialId
)
.then((serialDetails: XtreamSerieDetails) => {
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...serialDetails,
series_id: params.serialId,
});
})
.catch((error: unknown) => {
logger.error('Error fetching series details', error);
store.setDetailsError(
error instanceof Error
? error.message
: 'Unknown error'
);
})
.finally(() => {
store.setIsLoadingDetails(false);
});
}).catch((error: unknown) => {
logger.error('Error fetching series details', error);
store.setDetailsError(
error instanceof Error ? error.message : 'Unknown error'
);
}).finally(() => {
store.setIsLoadingDetails(false);
});
},
/**
@@ -230,9 +255,17 @@ export const XtreamStore = signalStore(
* Search content wrapper for rxMethod compatibility
* Can be called with object { term, types, excludeHidden } or direct params
*/
searchContent(params: { term: string; types: string[]; excludeHidden?: boolean }): void {
searchContent(params: {
term: string;
types: string[];
excludeHidden?: boolean;
}): void {
// Call the underlying search method from withSearch
void searchContent(params.term, params.types, params.excludeHidden);
void searchContent(
params.term,
params.types,
params.excludeHidden
);
},
};
})
@@ -10,11 +10,11 @@ import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { filter } from 'rxjs';
import { PortalRailSection } from '../../shared/navigation/portal-rail-links';
import {
isWorkspaceLayoutRoute,
resolveCurrentPortalSection,
} from '../../shared/navigation/portal-route.utils';
import { PortalRailSection } from '../../shared/navigation/portal-rail-links';
import { LoadingOverlayComponent } from '../loading-overlay/loading-overlay.component';
import { NavigationComponent } from '../navigation/navigation.component';
import { XtreamStore } from '../stores/xtream.store';
@@ -60,9 +60,20 @@ export class XtreamShellComponent {
return;
}
// If the global store already has this playlist loaded, don't wipe it out.
// This prevents reloading everything when returning from other views (e.g. Sources).
if (this.xtreamStore.playlistId() !== newPlaylistId) {
// Always reset the store when this is a fresh shell instance
// (currentPlaylistId is null = newly created component). This
// handles the refresh-from-sources flow where the DB content was
// cleared but the root-scoped store still holds stale
// isContentInitialized: true from the previous session.
//
// When navigating between sub-routes within the same shell
// (live → vod → series), currentPlaylistId is already set so
// reset is skipped and in-memory content is preserved.
const isFreshInstance = this.currentPlaylistId === null;
if (
isFreshInstance ||
this.xtreamStore.playlistId() !== newPlaylistId
) {
this.xtreamStore.resetStore(newPlaylistId);
}
+35 -6
View File
@@ -555,11 +555,6 @@
"GRID_VIEW": "Grid view",
"NO_FAVORITES": "No favorites found for this filter.",
"NO_FAVORITES_HINT": "You haven't added any channels, movies, or series to your global favorites yet. Use the star icon to favorite items.",
"RECENT_SOURCES": "Recent Sources",
"MANAGE_ALL": "Manage all",
"NO_SOURCES": "No sources available yet.",
"NO_SOURCES_HINT": "Your recently used or added playlists will show up here for quick access.",
"OPEN_SOURCES": "Open Sources",
"RECENTLY_WATCHED": "Recently Watched",
"ACTIVITY": "Activity",
"NO_HISTORY": "No items found for this filter.",
@@ -569,7 +564,41 @@
"TOTAL": "Total",
"XTREAM": "Xtream",
"STALKER": "Stalker",
"M3U": "M3U"
"M3U": "M3U",
"TOPBAR_LABEL": "Dashboard",
"TOOLBAR_RESET": "Reset Layout",
"TOOLBAR_CUSTOMIZE": "Customize dashboard",
"TOOLBAR_DONE": "Done",
"TOOLBAR_HINT_EDITING": "Drag cards by the handle. Resize inline. Use the sidebar for visibility and scope.",
"WIDGET_LIBRARY_TITLE": "Widget Library",
"WIDGET_LIBRARY_HINT": "Show/hide widgets and configure provider scope without leaving the canvas.",
"WIDGET_VISIBILITY": "Visibility",
"WIDGET_VISIBLE": "Visible",
"WIDGET_ADVANCED": "Advanced settings",
"WIDGET_SCOPE": "Scope",
"WIDGET_SCOPE_PROVIDERS": "Provider filters:",
"WIDGET_SCOPE_ALL_HINT": "No providers selected means all providers are included.",
"WIDGET_SCOPE_PLAYLISTS": "Playlists (leave empty to use all playlists in current provider scope)",
"WIDGET_SCOPE_EMPTY": "No playlists available for selected providers.",
"EMPTY_WIDGETS": "No visible widgets",
"EMPTY_WIDGETS_HINT": "Enable at least one widget from the sidebar.",
"NO_HISTORY_CW": "Nothing watched yet",
"NO_HISTORY_CW_HINT": "Start watching any channel, movie or series and it will appear here.",
"TYPE_LIVE": "Live",
"TYPE_MOVIE": "Movie",
"TYPE_SERIES": "Series",
"ON_AIR_EYEBROW": "On Air",
"ON_AIR_TITLE": "Currently Airing",
"ON_AIR_EMPTY": "No EPG data available",
"ON_AIR_EMPTY_HINT": "Load an EPG source in Settings to see what's on right now.",
"ON_AIR_EMPTY_FILTER": "No programs match this category.",
"ON_AIR_ADD_EPG": "Add EPG Source",
"ON_AIR_CAT_ALL": "All",
"ON_AIR_CAT_SPORTS": "Sports",
"ON_AIR_CAT_NEWS": "News",
"ON_AIR_CAT_MOVIES": "Movies",
"ON_AIR_CAT_KIDS": "Kids",
"MIN_LEFT": "min left"
}
}
}
+2
View File
@@ -82,6 +82,7 @@ html {
--app-widget-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.04);
--app-widget-header-border: rgba(0, 0, 0, 0.06);
--app-card-hover-bg: #f0f2f5;
--app-separator: rgba(0, 0, 0, 0.14);
// Typography — subdued, not pure black
--app-heading-color: #1a1d23;
@@ -149,6 +150,7 @@ html {
--app-widget-shadow: none;
--app-widget-header-border: rgba(255, 255, 255, 0.05);
--app-card-hover-bg: #252b3a;
--app-separator: rgba(255, 255, 255, 0.14);
// Typography — NOT pure white. Warm graphite-tinted whites
--app-heading-color: #d8dce8; // soft off-white, not #fff
@@ -84,29 +84,36 @@ export const playlistReducers = [
}
),
on(PlaylistActions.updatePlaylistMeta, (state, action): PlaylistState => {
const p = action.playlist;
return {
...state,
playlists: playlistsAdapter.updateOne(
{
id: action.playlist._id,
id: p._id,
changes: {
title: action.playlist.title,
autoRefresh: action.playlist.autoRefresh || false,
userAgent: action.playlist.userAgent,
...(action.playlist.serverUrl !== null
? { serverUrl: action.playlist.serverUrl }
...(p.title != null ? { title: p.title } : {}),
...(p.autoRefresh != null
? { autoRefresh: p.autoRefresh }
: {}),
...(action.playlist.username !== null
? { username: action.playlist.username }
...(p.userAgent != null
? { userAgent: p.userAgent }
: {}),
...(action.playlist.password !== null
? { password: action.playlist.password }
...(p.serverUrl != null
? { serverUrl: p.serverUrl }
: {}),
...(action.playlist.macAddress !== null
? { macAddress: action.playlist.macAddress }
...(p.username != null ? { username: p.username } : {}),
...(p.password != null ? { password: p.password } : {}),
...(p.macAddress != null
? { macAddress: p.macAddress }
: {}),
...(action.playlist.portalUrl !== null
? { portalUrl: action.playlist.portalUrl }
...(p.portalUrl != null
? { portalUrl: p.portalUrl }
: {}),
...(p.favorites != null
? { favorites: p.favorites }
: {}),
...(p.recentlyViewed != null
? { recentlyViewed: p.recentlyViewed }
: {}),
},
},
+28 -15
View File
@@ -142,7 +142,10 @@ export class PlaylistsService {
* Infer the mode from URL when legacy records are missing explicit flag.
*/
private normalizeStalkerPortalFlags(playlist: Playlist): Playlist {
if (!playlist?.macAddress || playlist.isFullStalkerPortal !== undefined) {
if (
!playlist?.macAddress ||
playlist.isFullStalkerPortal !== undefined
) {
return playlist;
}
@@ -264,24 +267,36 @@ export class PlaylistsService {
switchMap((playlist) => {
const nextPlaylist: Playlist = {
...playlist,
title: updatedPlaylist.title,
autoRefresh: updatedPlaylist.autoRefresh,
userAgent: updatedPlaylist.userAgent,
...(updatedPlaylist.serverUrl !== null
...(updatedPlaylist.title != null
? { title: updatedPlaylist.title }
: {}),
...(updatedPlaylist.autoRefresh != null
? { autoRefresh: updatedPlaylist.autoRefresh }
: {}),
...(updatedPlaylist.userAgent != null
? { userAgent: updatedPlaylist.userAgent }
: {}),
...(updatedPlaylist.serverUrl != null
? { serverUrl: updatedPlaylist.serverUrl }
: {}),
...(updatedPlaylist.portalUrl !== null
...(updatedPlaylist.portalUrl != null
? { portalUrl: updatedPlaylist.portalUrl }
: {}),
...(updatedPlaylist.macAddress !== null
...(updatedPlaylist.macAddress != null
? { macAddress: updatedPlaylist.macAddress }
: {}),
...(updatedPlaylist.username !== null
...(updatedPlaylist.username != null
? { username: updatedPlaylist.username }
: {}),
...(updatedPlaylist.password !== null
...(updatedPlaylist.password != null
? { password: updatedPlaylist.password }
: {}),
...(updatedPlaylist.favorites != null
? { favorites: updatedPlaylist.favorites }
: {}),
...(updatedPlaylist.recentlyViewed != null
? { recentlyViewed: updatedPlaylist.recentlyViewed }
: {}),
...(updatedPlaylist.updateDate !== undefined
? { updateDate: updatedPlaylist.updateDate }
: {}),
@@ -608,12 +623,10 @@ export class PlaylistsService {
getAllData() {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(
async () =>
((await window.electron.dbGetAppPlaylists()) as Playlist[])
.map((playlist) =>
this.normalizeStalkerPortalFlags(playlist)
)
return this.runOnSqlite(async () =>
((await window.electron.dbGetAppPlaylists()) as Playlist[]).map(
(playlist) => this.normalizeStalkerPortalFlags(playlist)
)
);
}
@@ -146,6 +146,7 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_stop ON epg_programs(stop)`,
`CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop)`,
// FTS5 virtual table for full-text search on EPG programs
`CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5(
+1
View File
@@ -176,6 +176,7 @@ export const epgPrograms = sqliteTable(
(table) => ({
channelIdx: index('idx_epg_programs_channel').on(table.channelId),
startIdx: index('idx_epg_programs_start').on(table.start),
stopIdx: index('idx_epg_programs_stop').on(table.stop),
timeRangeIdx: index('idx_epg_programs_time_range').on(
table.channelId,
table.start,
@@ -239,32 +239,43 @@ export class RecentPlaylistsComponent {
),
onConfirm: async () => {
try {
// Delete all content and categories for this playlist
// This returns the xtreamIds of favorites, recently viewed items, and hidden categories
const {
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
} = await this.databaseService.deleteXtreamPlaylistContent(
item._id
// Show immediate feedback — deletion can take several seconds
// for large playlists.
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
// Delete content/categories and update the timestamp in
// parallel — both operations are fully independent.
const updateDate = Date.now();
const [
{
favoritedXtreamIds,
recentlyViewedXtreamIds,
hiddenCategories,
},
] = await Promise.all([
this.databaseService.deleteXtreamPlaylistContent(
item._id
),
this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
}),
]);
// Update the updateDate timestamp in database (Electron)
await this.databaseService.updateXtreamPlaylistDetails({
id: item._id,
updateDate,
});
// Update the updateDate timestamp in IndexedDB
// Update the timestamp in NgRx / IndexedDB
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate },
})
);
// Store the user data in localStorage to restore after re-import
// Persist user data so it can be restored after re-import
const restoreKey = `xtream-restore-${item._id}`;
localStorage.setItem(
restoreKey,
@@ -275,14 +286,6 @@ export class RecentPlaylistsComponent {
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.STARTED'
),
undefined,
{ duration: 2000 }
);
// Navigate to the playlist to trigger re-import
this.router.navigate(['/workspace', 'xtreams', item._id]);
} catch (error) {