mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
|
||||
+40
-29
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
+4
-6
@@ -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>
|
||||
+32
-138
@@ -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;
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -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,
|
||||
|
||||
+4
@@ -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>
|
||||
+55
@@ -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>();
|
||||
}
|
||||
+17
-12
@@ -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>
|
||||
+2
-126
@@ -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;
|
||||
}
|
||||
}
|
||||
+26
-30
@@ -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()
|
||||
);
|
||||
|
||||
+14
-11
@@ -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>
|
||||
+46
-127
@@ -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%;
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user