feat: Enhance settings context service with pending scroll target management

feat: Update category view to improve empty state representation

style: Refactor category view styles for better layout and responsiveness

fix: Adjust favorites component navigation logic for better routing

refactor: Improve playlist error view structure and styles for clarity

style: Revamp playlist error view styles for consistency and readability

feat: Update portal channels list to use new channel list item component

style: Clean up portal channels list styles for improved layout

refactor: Optimize portal channels list component logic and structure

feat: Implement recently viewed component navigation improvements

fix: Update global search results component for better routing

test: Add unit tests for search results component functionality

feat: Enhance search results component to handle initial query

fix: Update EPG queue service to manage preview limits effectively

style: Clean up nav list styles for better visual consistency
This commit is contained in:
4gray committed 2026-03-05 23:35:45 +01:00
1 parent e24967fc68
commit ee411393ce
26 files changed
+1163 -523

No files matched your search

@@ -0,0 +1,152 @@
const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
const STALKER_STREAM_RANGE_HEADER = 'bytes=0-';
const CONTEXT_TTL_MS = 15 * 60 * 1000;
type PlaybackContext = {
headers: Record<string, string>;
createdAt: number;
};
const playbackContextByStream = new Map<string, PlaybackContext>();
const playbackContextByOrigin = new Map<string, PlaybackContext>();
function normalizeStreamUrl(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
try {
const parsed = new URL(maybeWrapped);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return maybeWrapped.split('?')[0];
}
}
function getOrigin(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
try {
return new URL(maybeWrapped).origin;
} catch {
return '';
}
}
function removeExpiredContextEntries() {
const now = Date.now();
for (const [key, value] of playbackContextByStream.entries()) {
if (now - value.createdAt > CONTEXT_TTL_MS) {
playbackContextByStream.delete(key);
}
}
for (const [key, value] of playbackContextByOrigin.entries()) {
if (now - value.createdAt > CONTEXT_TTL_MS) {
playbackContextByOrigin.delete(key);
}
}
}
export function rememberStalkerPlaybackContext(input: {
streamUrl: string;
portalUrl: string;
macAddress: string;
serialNumber?: string;
token?: string;
}) {
const streamKey = normalizeStreamUrl(input.streamUrl);
if (!streamKey || !input.macAddress) return;
const cookieParts = [
`mac=${input.macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (input.serialNumber) {
cookieParts.push(
`__cfduid=${input.serialNumber.toLowerCase()}e030245495acd6ebfc1`
);
}
const streamOrigin = getOrigin(input.streamUrl) || undefined;
let portalOrigin: string | undefined;
try {
portalOrigin = new URL(input.portalUrl).origin;
} catch {
portalOrigin = undefined;
}
const crossOriginStream =
Boolean(streamOrigin) &&
Boolean(portalOrigin) &&
streamOrigin !== portalOrigin;
const headers: Record<string, string> = crossOriginStream
? {
// Align with known working clients for direct tokenized stream URLs.
'User-Agent': STALKER_STREAM_USER_AGENT,
Accept: '*/*',
Range: STALKER_STREAM_RANGE_HEADER,
'Icy-MetaData': '1',
Connection: 'keep-alive',
}
: {
Cookie: cookieParts.join('; '),
'User-Agent': STALKER_MAG_USER_AGENT,
'X-User-Agent': STALKER_MAG_USER_AGENT,
};
if (!crossOriginStream && portalOrigin) {
headers['Origin'] = portalOrigin;
headers['Referer'] = portalOrigin;
}
if (!crossOriginStream && input.serialNumber) {
headers['SN'] = input.serialNumber;
}
if (!crossOriginStream && input.token) {
headers['Authorization'] = `Bearer ${input.token}`;
}
playbackContextByStream.set(streamKey, {
headers,
createdAt: Date.now(),
});
if (streamOrigin) {
playbackContextByOrigin.set(streamOrigin, {
headers,
createdAt: Date.now(),
});
}
removeExpiredContextEntries();
}
export function getStalkerPlaybackContextHeaders(
streamUrl: string
): Record<string, string> | null {
removeExpiredContextEntries();
const streamKey = normalizeStreamUrl(streamUrl);
if (streamKey) {
const entry = playbackContextByStream.get(streamKey);
if (entry) {
return { ...entry.headers };
}
}
const origin = getOrigin(streamUrl);
if (!origin) return null;
const originEntry = playbackContextByOrigin.get(origin);
return originEntry ? { ...originEntry.headers } : null;
}
+2 -2
View File
@@ -146,13 +146,13 @@ export class AppComponent implements OnInit {
this.router.navigateByUrl(route);
}
openGlobalSearch(): void {
openGlobalSearch(initialQuery = ''): void {
this.dialog.open(GlobalSearchResultsComponent, {
width: '100%',
height: '100%',
maxWidth: '100%',
panelClass: 'global-search-overlay',
data: { isGlobalSearch: true },
data: { isGlobalSearch: true, initialQuery },
});
}
@@ -42,6 +42,7 @@
// Width is controlled by appResizable directive
// Default: 350px, min: 200px, max: 600px
overflow-x: hidden !important; // Hide horizontal scrollbar
border-radius: 0 !important;
.resize-handle--right {
right: 0;
@@ -13,6 +13,20 @@
class="settings-layout"
novalidate
>
<section class="settings-intro" id="settings-intro">
<div class="settings-intro__content">
<div class="settings-intro__eyebrow">
{{ 'SETTINGS.HERO_EYEBROW' | translate }}
</div>
<h1 class="settings-intro__title">
{{ 'SETTINGS.HERO_TITLE' | translate }}
</h1>
<p class="settings-intro__lead">
{{ 'SETTINGS.HERO_DESCRIPTION' | translate }}
</p>
</div>
</section>
<div class="settings-content">
<section
class="settings-group"
@@ -119,7 +133,7 @@
<mat-icon>play_circle</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}</h3>
<h3>{{ 'SETTINGS.PLAYBACK' | translate }}</h3>
<p>
{{
'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate
@@ -150,7 +164,7 @@
<mat-option
[value]="player.id"
[attr.data-test-id]="player.id"
>{{ player.label }}
>{{ player.labelKey | translate }}
</mat-option>
}
</mat-select>
@@ -287,8 +301,13 @@
<mat-icon>list_alt</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.EPG_URL_LABEL' | translate }}</h3>
<p>Add single or multiple URLs as EPG sources</p>
<h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
<p>
{{
'SETTINGS.EPG_SOURCES_DESCRIPTION'
| translate
}}
</p>
</div>
</header>
@@ -438,7 +457,9 @@
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline">
<mat-label>Port (1-65535)</mat-label>
<mat-label>{{
'SETTINGS.PORT_RANGE_LABEL' | translate
}}</mat-label>
<input
matInput
type="number"
@@ -574,7 +595,7 @@
<mat-icon>sync_alt</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}</h3>
<h3>{{ 'SETTINGS.DATA' | translate }}</h3>
<p>
{{
'SETTINGS.IMPORT_EXPORT_DATA_DESCRIPTION'
@@ -663,7 +684,7 @@
<mat-icon>info</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.VERSION' | translate }}</h3>
<h3>{{ 'SETTINGS.ABOUT' | translate }}</h3>
<p>{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}</p>
</div>
</header>
+196 -34
View File
@@ -1,11 +1,128 @@
:host {
display: block;
--settings-intro-bg: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96) 0%,
color-mix(in srgb, var(--mat-sys-surface-container-low) 88%, white) 100%
);
--settings-intro-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 80%,
transparent
);
--settings-intro-shadow:
0 10px 24px -22px rgba(15, 23, 42, 0.26),
0 1px 0 rgba(255, 255, 255, 0.8) inset;
--settings-group-bg: rgba(255, 255, 255, 0.72);
--settings-group-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 70%,
transparent
);
--settings-group-shadow: 0 10px 24px -24px rgba(15, 23, 42, 0.18);
--settings-group-active-bg: color-mix(
in srgb,
var(--app-selection-color) 4%,
rgba(255, 255, 255, 0.94)
);
--settings-group-active-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 14px 28px -26px var(--app-selection-glow);
--settings-item-bg: rgba(255, 255, 255, 0.92);
--settings-item-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 72%,
transparent
);
--settings-item-shadow: 0 6px 20px -24px rgba(15, 23, 42, 0.18);
--settings-item-danger-bg: color-mix(
in srgb,
var(--mat-sys-error) 5%,
rgba(255, 255, 255, 0.94)
);
--settings-item-danger-border: color-mix(
in srgb,
var(--mat-sys-error) 18%,
transparent
);
--settings-action-bar-bg: color-mix(
in srgb,
var(--mat-sys-surface-container-low) 90%,
rgba(255, 255, 255, 0.86)
);
--settings-action-bar-border: color-mix(
in srgb,
var(--mat-sys-outline-variant) 68%,
transparent
);
--settings-action-bar-shadow: 0 10px 26px -24px rgba(15, 23, 42, 0.28);
--settings-muted: color-mix(
in srgb,
var(--mat-sys-on-surface) 68%,
transparent
);
}
:host-context(.dark-theme),
:host-context([data-theme='dark']),
:host-context(.mat-app-background.dark) {
--settings-intro-bg: color-mix(
in srgb,
var(--app-selection-surface) 72%,
rgba(10, 16, 26, 0.96)
);
--settings-intro-border: color-mix(
in srgb,
var(--app-selection-border) 55%,
transparent
);
--settings-intro-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 18px 28px -30px rgba(0, 0, 0, 0.45);
--settings-group-bg: rgba(255, 255, 255, 0.02);
--settings-group-border: rgba(255, 255, 255, 0.06);
--settings-group-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 14px 26px -26px rgba(0, 0, 0, 0.45);
--settings-group-active-bg: color-mix(
in srgb,
var(--app-selection-surface) 82%,
rgba(255, 255, 255, 0.02)
);
--settings-group-active-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 16px 28px -24px var(--app-selection-glow);
--settings-item-bg: rgba(8, 12, 20, 0.3);
--settings-item-border: rgba(255, 255, 255, 0.05);
--settings-item-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
--settings-item-danger-bg: color-mix(
in srgb,
var(--mat-sys-error) 10%,
rgba(24, 10, 12, 0.9)
);
--settings-item-danger-border: color-mix(
in srgb,
var(--mat-sys-error) 34%,
transparent
);
--settings-action-bar-bg: color-mix(
in srgb,
var(--mat-sys-surface-container) 88%,
transparent
);
--settings-action-bar-border: var(--mat-sys-outline-variant);
--settings-action-bar-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
--settings-muted: color-mix(
in srgb,
var(--mat-sys-on-surface) 74%,
transparent
);
}
.settings-container {
height: auto;
overflow: visible;
padding: 20px;
padding: 20px 20px 32px;
}
.settings-container.dialog-mode {
@@ -18,48 +135,81 @@
.settings-layout {
display: flex;
flex-direction: column;
gap: 32px;
gap: 24px;
align-items: stretch;
max-width: 800px;
max-width: 960px;
margin: 0 auto;
padding-bottom: 80px; /* space for FAB */
}
.settings-intro {
position: relative;
overflow: hidden;
padding: 28px;
border: 1px solid var(--settings-intro-border);
border-radius: 28px;
background: var(--settings-intro-bg);
box-shadow: var(--settings-intro-shadow);
}
.settings-intro__content {
position: relative;
z-index: 1;
max-width: 640px;
}
.settings-intro__eyebrow {
margin-bottom: 10px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--app-selection-color);
opacity: 0.86;
}
.settings-intro__title {
margin: 0;
font-size: clamp(1.8rem, 3vw, 2.6rem);
line-height: 1;
letter-spacing: -0.04em;
font-weight: 800;
}
.settings-intro__lead {
margin: 12px 0 0;
max-width: 52ch;
color: color-mix(in srgb, var(--mat-sys-on-surface) 78%, transparent);
font-size: 0.98rem;
line-height: 1.5;
}
.settings-content {
min-width: 0;
display: flex;
flex-direction: column;
gap: 32px;
gap: 20px;
}
.settings-group {
position: relative;
background: transparent;
background: var(--settings-group-bg);
overflow: hidden;
scroll-margin-top: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
border-radius: 18px;
padding: 4px;
border: 1px solid var(--settings-group-border);
border-radius: 24px;
box-shadow: var(--settings-group-shadow);
transition:
background 0.2s ease,
box-shadow 0.2s ease,
border-color 0.2s ease;
&:last-child {
border-bottom: none;
}
}
.settings-group--active {
background: linear-gradient(
145deg,
var(--app-selection-surface),
color-mix(in srgb, var(--app-selection-color) 6%, transparent)
);
background: var(--settings-group-active-bg);
border-color: var(--app-selection-border);
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 16px 28px -24px var(--app-selection-glow);
box-shadow: var(--settings-group-active-shadow);
.settings-group__header-icon {
background: var(--app-selection-surface-strong);
@@ -76,7 +226,7 @@
display: flex;
align-items: center;
gap: 16px;
padding: 24px 16px 12px;
padding: 20px 18px 14px;
.settings-group__header-icon {
display: flex;
@@ -113,8 +263,8 @@
}
.settings-group__footer {
padding: 16px 16px 0;
margin-top: 16px;
padding: 14px 16px 12px;
margin-top: 8px;
border-top: 1px dashed var(--mat-sys-outline-variant);
}
@@ -122,16 +272,20 @@
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(240px, 440px);
gap: 18px;
margin: 0 10px 10px;
padding: 16px 16px;
border-bottom: 1px dashed var(--mat-sys-surface-variant);
border: 1px solid var(--settings-item-border);
border-radius: 18px;
background: var(--settings-item-bg);
box-shadow: var(--settings-item-shadow);
&:last-of-type {
border-bottom: none;
margin-bottom: 0;
}
}
.settings-group .setting-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.setting-item__meta {
@@ -262,24 +416,25 @@
}
.danger-zone {
background: color-mix(in srgb, var(--mat-sys-error) 8%, transparent);
background: var(--settings-item-danger-bg);
border-color: var(--settings-item-danger-border);
}
.action-bar {
position: fixed;
bottom: 32px;
right: 32px;
position: sticky;
bottom: 24px;
margin-left: auto;
z-index: 100;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
padding: 12px 20px;
border: 1px solid var(--mat-sys-outline-variant);
border: 1px solid var(--settings-action-bar-border);
border-radius: 99px; /* Pill shape */
background: color-mix(in srgb, var(--mat-sys-surface-container) 88%, transparent);
background: var(--settings-action-bar-bg);
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
box-shadow: var(--settings-action-bar-shadow);
}
.settings-container.dialog-mode .action-bar {
@@ -294,7 +449,7 @@
@media (max-width: 1100px) {
.settings-layout {
max-width: 100%;
padding: 0 16px;
padding: 0;
}
}
@@ -304,6 +459,11 @@
padding: 12px;
}
.settings-intro {
padding: 22px 18px;
border-radius: 24px;
}
.setting-item {
grid-template-columns: 1fr;
gap: 12px;
@@ -336,6 +496,8 @@
align-items: stretch;
border-radius: 16px;
box-shadow: none;
width: 100%;
margin-left: 0;
button {
width: 100%;
+166 -17
View File
@@ -2,9 +2,11 @@
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
effect,
inject,
Inject,
Injector,
Input,
OnDestroy,
OnInit,
@@ -58,6 +60,11 @@ interface SettingsSection {
visible: boolean;
}
interface ObservedSettingsSection {
id: string;
element: HTMLElement;
}
@Component({
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
@@ -89,6 +96,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
private store = inject(Store);
private translate = inject(TranslateService);
private matDialog = inject(MatDialog);
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly injector = inject(Injector);
@Input() isDialog = false;
/** List with available languages as enum */
@@ -108,11 +117,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly osPlayers = [
{
id: VideoPlayer.MPV,
label: 'MPV Player',
labelKey: 'SETTINGS.PLAYER_MPV',
},
{
id: VideoPlayer.VLC,
label: 'VLC',
labelKey: 'SETTINGS.PLAYER_VLC',
},
];
@@ -120,15 +129,15 @@ export class SettingsComponent implements OnInit, OnDestroy {
readonly players = [
{
id: VideoPlayer.Html5Player,
label: 'HTML5 Video Player',
labelKey: 'SETTINGS.PLAYER_HTML5',
},
{
id: VideoPlayer.VideoJs,
label: 'VideoJs Player',
labelKey: 'SETTINGS.PLAYER_VIDEOJS',
},
{
id: VideoPlayer.ArtPlayer,
label: 'ArtPlayer',
labelKey: 'SETTINGS.PLAYER_ARTPLAYER',
},
...(this.isDesktop ? this.osPlayers : []),
];
@@ -178,41 +187,42 @@ export class SettingsComponent implements OnInit, OnDestroy {
visibleQrCodeIp = signal<string | null>(null);
private settingsStore = inject(SettingsStore);
private sectionObserver?: IntersectionObserver;
readonly sectionNavItems: SettingsSection[] = [
{
id: 'general',
label: 'SETTINGS.GENERAL',
label: 'SETTINGS.NAV_GENERAL',
icon: 'tune',
visible: true,
},
{
id: 'playback',
label: 'SETTINGS.VIDEO_PLAYER_LABEL',
label: 'SETTINGS.NAV_PLAYBACK',
icon: 'play_circle',
visible: true,
},
{
id: 'epg',
label: 'SETTINGS.EPG_URL_LABEL',
label: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
visible: this.isDesktop,
},
{
id: 'remote-control',
label: 'SETTINGS.REMOTE_CONTROL',
label: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
visible: this.isDesktop,
},
{
id: 'data',
label: 'SETTINGS.IMPORT_EXPORT_DATA',
label: 'SETTINGS.NAV_DATA',
icon: 'swap_horiz',
visible: true,
},
{
id: 'about',
label: 'SETTINGS.VERSION',
label: 'SETTINGS.NAV_ABOUT',
icon: 'info',
visible: true,
},
@@ -223,14 +233,63 @@ export class SettingsComponent implements OnInit, OnDestroy {
) {
this.isDialog = data?.isDialog ?? false;
effect(() => {
const sectionId = this.settingsCtx.activeSection();
if (sectionId && typeof document !== 'undefined') {
effect(
() => {
const sectionId = this.settingsCtx.pendingScrollTarget();
if (!sectionId || typeof document === 'undefined') {
return;
}
const scrollTargetId =
sectionId === 'general' ? 'settings-intro' : sectionId;
document
.getElementById(sectionId)
.getElementById(scrollTargetId)
?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
this.settingsCtx.clearPendingScrollTarget();
},
{ injector: this.injector }
);
effect(
(onCleanup) => {
const activeSectionId = this.activeSection();
const activeSectionElement =
this.elementRef.nativeElement.querySelector(
`#${activeSectionId}`
) as HTMLElement | null;
if (!activeSectionElement || activeSectionId === 'general') {
return;
}
const animation = activeSectionElement.animate(
[
{
transform: 'translateY(0)',
boxShadow:
'inset 0 0 0 1px var(--app-selection-border), 0 12px 20px -18px var(--app-selection-glow)',
},
{
transform: 'translateY(-2px)',
boxShadow:
'inset 0 0 0 1px var(--app-selection-border), 0 18px 28px -18px var(--app-selection-glow)',
},
{
transform: 'translateY(0)',
boxShadow:
'inset 0 0 0 1px var(--app-selection-border), 0 12px 20px -18px var(--app-selection-glow)',
},
],
{
duration: 260,
easing: 'ease-out',
}
);
onCleanup(() => animation.cancel());
},
{ injector: this.injector }
);
}
get sectionNav(): SettingsSection[] {
@@ -251,9 +310,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
if (!this.isDialog) {
this.settingsCtx.setSections(this.sectionNav);
}
requestAnimationFrame(() => this.setupSectionObserver());
}
ngOnDestroy(): void {
this.sectionObserver?.disconnect();
this.settingsCtx.reset();
}
@@ -556,4 +618,91 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.store.dispatch(PlaylistActions.removeAllPlaylists()),
});
}
private setupSectionObserver(): void {
if (typeof IntersectionObserver === 'undefined') {
return;
}
const scrollRoot = this.getScrollRoot();
const introSection = this.elementRef.nativeElement.querySelector(
'#settings-intro'
) as HTMLElement | null;
const contentSections = Array.from(
this.elementRef.nativeElement.querySelectorAll(
'.settings-group[id]'
)
) as HTMLElement[];
const sections: ObservedSettingsSection[] = [
...(introSection
? [{ id: 'general', element: introSection }]
: []),
...contentSections.map((section) => ({
id: section.id,
element: section,
})),
];
if (sections.length === 0) {
return;
}
this.sectionObserver?.disconnect();
this.sectionObserver = new IntersectionObserver(
() => {
const activeSection = this.resolveActiveSection(sections);
if (activeSection) {
this.settingsCtx.setActiveSection(activeSection);
}
},
{
root: scrollRoot,
threshold: [0.12, 0.24, 0.4, 0.6],
rootMargin: '-18% 0px -52% 0px',
}
);
sections.forEach((section) =>
this.sectionObserver?.observe(section.element)
);
const initialSection = this.resolveActiveSection(sections);
if (initialSection) {
this.settingsCtx.setActiveSection(initialSection);
}
}
private resolveActiveSection(
sections: ObservedSettingsSection[]
): string | null {
const scrollRoot = this.getScrollRoot();
const rootTop = scrollRoot?.getBoundingClientRect().top ?? 0;
const rootHeight = scrollRoot?.clientHeight ?? window.innerHeight;
const activationLine = rootTop + Math.min(rootHeight * 0.28, 220);
const sectionAtActivationLine = sections.find((section) => {
const rect = section.element.getBoundingClientRect();
return rect.top <= activationLine && rect.bottom >= activationLine;
});
if (sectionAtActivationLine) {
return sectionAtActivationLine.id;
}
const nearestSection = sections
.map((section) => ({
id: section.id,
distance: Math.abs(
section.element.getBoundingClientRect().top - activationLine
),
}))
.sort((a, b) => a.distance - b.distance)[0];
return nearestSection?.id ?? null;
}
private getScrollRoot(): HTMLElement | null {
return this.elementRef.nativeElement.closest(
'main.workspace-content'
) as HTMLElement | null;
}
}
@@ -111,6 +111,28 @@ mat-card:hover {
width: 100%;
}
.grid-empty-state {
grid-column: 1 / -1;
min-height: clamp(320px, 56vh, 620px);
display: flex;
align-items: center;
justify-content: center;
padding: clamp(12px, 2.8vw, 30px);
box-sizing: border-box;
app-playlist-error-view {
--error-empty-illustration-size: clamp(170px, 18vw, 250px);
--error-empty-max-width: min(480px, 100%);
--error-empty-title-size: clamp(1.45rem, 2vw, 1.9rem);
--error-empty-description-size: clamp(0.95rem, 1.15vw, 1.05rem);
--error-empty-title-weight: 660;
--error-empty-title-spacing: -0.014em;
--error-empty-copy-gap: 10px;
--error-empty-description-line-height: 1.44;
--error-empty-description-opacity: 0.9;
}
}
.grid-skeleton-thumb {
position: relative;
width: 100%;
@@ -200,3 +222,19 @@ mat-card:hover {
transform: translateX(120%);
}
}
@media (max-width: 768px) {
.grid-empty-state {
min-height: clamp(260px, 48vh, 460px);
padding: 14px;
app-playlist-error-view {
--error-empty-illustration-size: 165px;
--error-empty-max-width: 310px;
--error-empty-title-size: 1.3rem;
--error-empty-description-size: 0.9rem;
--error-empty-title-weight: 640;
--error-empty-copy-gap: 8px;
}
}
}
@@ -100,18 +100,20 @@ interface GridListItem {
</mat-card-actions>
</mat-card>
} @empty {
<app-playlist-error-view
[title]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
| translate
"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
| translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
<div class="grid-empty-state">
<app-playlist-error-view
[title]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE'
| translate
"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION'
| translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
</div>
}
}
</div>
@@ -1,8 +1,7 @@
import { GLOBAL_FAVORITES_PLAYLIST_ID } from 'shared-interfaces';
import { buildPortalRailLinks } from './portal-rail-links';
describe('buildPortalRailLinks', () => {
it('builds legacy Xtream links with downloads on Electron', () => {
it('builds Xtream links with scoped tooltip labels on Electron', () => {
const links = buildPortalRailLinks({
provider: 'xtreams',
playlistId: 'xtream-1',
@@ -22,7 +21,10 @@ describe('buildPortalRailLinks', () => {
'favorites',
'downloads',
]);
expect(links.primary[0]?.path).toEqual(['/xtreams', 'xtream-1', 'vod']);
expect(links.primary[0]?.tooltip).toBe('Movies (this playlist)');
expect(links.secondary[3]?.tooltip).toBe('Favorites (this playlist)');
expect(links.secondary[4]?.tooltip).toBe('Downloads (this playlist)');
});
it('builds workspace Xtream links without downloads on web', () => {
@@ -36,7 +38,7 @@ describe('buildPortalRailLinks', () => {
expect(links.primary).toEqual([
{
icon: 'movie',
tooltip: 'Xtream library',
tooltip: 'Xtream library (this playlist)',
path: ['/workspace', 'xtreams', 'xtream-web'],
exact: true,
section: 'library',
@@ -45,7 +47,7 @@ describe('buildPortalRailLinks', () => {
expect(links.secondary).toEqual([]);
});
it('builds Stalker links with mapped itv section', () => {
it('builds Stalker links with scoped tooltip labels', () => {
const links = buildPortalRailLinks({
provider: 'stalker',
playlistId: 'portal-1',
@@ -63,10 +65,13 @@ describe('buildPortalRailLinks', () => {
'recent',
'favorites',
]);
expect(links.primary[1]?.path).toEqual(['/stalker', 'portal-1', 'itv']);
expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)');
expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)');
expect(links.secondary[2]?.tooltip).toBe('Favorites (this playlist)');
});
it('builds playlist links with global favorites in workspace', () => {
it('builds M3U playlist links with scoped tooltip labels', () => {
const links = buildPortalRailLinks({
provider: 'playlists',
playlistId: 'm3u-1',
@@ -76,21 +81,27 @@ describe('buildPortalRailLinks', () => {
expect(links.primary).toEqual([
{
icon: 'play_circle',
tooltip: 'Player',
path: ['/workspace', 'playlists', 'm3u-1'],
icon: 'list',
tooltip: 'All channels (this playlist)',
path: ['/workspace', 'playlists', 'm3u-1', 'all'],
exact: true,
section: 'player',
section: 'all',
},
]);
expect(links.secondary).toEqual([
{
icon: 'favorite',
tooltip: 'Global favorites',
path: ['/workspace', 'playlists', GLOBAL_FAVORITES_PLAYLIST_ID],
icon: 'folder',
tooltip: 'Groups (this playlist)',
path: ['/workspace', 'playlists', 'm3u-1', 'groups'],
exact: true,
section: 'groups',
},
{
icon: 'star',
tooltip: 'Favorites (this playlist)',
path: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
exact: true,
section: 'favorites',
},
]);
expect(links.secondary).toEqual([]);
});
});
@@ -35,7 +35,7 @@ export function buildPortalRailLinks(
if (workspace && !isElectron) {
primary.push({
icon: 'movie',
tooltip: 'Xtream library',
tooltip: 'Xtream library (this playlist)',
path: root,
exact: true,
section: 'library',
@@ -46,19 +46,19 @@ export function buildPortalRailLinks(
primary.push(
{
icon: 'movie',
tooltip: 'Movies',
tooltip: 'Movies (this playlist)',
path: [...root, 'vod'],
section: 'vod',
},
{
icon: 'live_tv',
tooltip: 'Live TV',
tooltip: 'Live TV (this playlist)',
path: [...root, 'live'],
section: 'live',
},
{
icon: 'tv',
tooltip: 'Series',
tooltip: 'Series (this playlist)',
path: [...root, 'series'],
section: 'series',
}
@@ -67,25 +67,25 @@ export function buildPortalRailLinks(
secondary.push(
{
icon: 'new_releases',
tooltip: 'Recently added',
tooltip: 'Recently added (this playlist)',
path: [...root, 'recently-added'],
section: 'recently-added',
},
{
icon: 'search',
tooltip: 'Search',
tooltip: 'Search (this playlist)',
path: [...root, 'search'],
section: 'search',
},
{
icon: 'history',
tooltip: 'Recently viewed',
tooltip: 'Recently viewed (this playlist)',
path: [...root, 'recent'],
section: 'recent',
},
{
icon: 'favorite',
tooltip: 'Favorites',
tooltip: 'Favorites (this playlist)',
path: [...root, 'favorites'],
section: 'favorites',
}
@@ -94,7 +94,7 @@ export function buildPortalRailLinks(
if (isElectron) {
secondary.push({
icon: 'download',
tooltip: 'Downloads',
tooltip: 'Downloads (this playlist)',
path: [...root, 'downloads'],
section: 'downloads',
});
@@ -107,19 +107,19 @@ export function buildPortalRailLinks(
const primary: PortalRailLink[] = [
{
icon: 'movie',
tooltip: 'Movies',
tooltip: 'Movies (this playlist)',
path: [...root, 'vod'],
section: 'vod',
},
{
icon: 'live_tv',
tooltip: 'Live TV',
tooltip: 'Live TV (this playlist)',
path: [...root, 'itv'],
section: 'itv',
},
{
icon: 'tv',
tooltip: 'Series',
tooltip: 'Series (this playlist)',
path: [...root, 'series'],
section: 'series',
},
@@ -128,19 +128,19 @@ export function buildPortalRailLinks(
const secondary: PortalRailLink[] = [
{
icon: 'search',
tooltip: 'Search',
tooltip: 'Search (this playlist)',
path: [...root, 'search'],
section: 'search',
},
{
icon: 'history',
tooltip: 'Recently viewed',
tooltip: 'Recently viewed (this playlist)',
path: [...root, 'recent'],
section: 'recent',
},
{
icon: 'favorite',
tooltip: 'Favorites',
tooltip: 'Favorites (this playlist)',
path: [...root, 'favorites'],
section: 'favorites',
},
@@ -149,7 +149,7 @@ export function buildPortalRailLinks(
if (isElectron) {
secondary.push({
icon: 'download',
tooltip: 'Downloads',
tooltip: 'Downloads (this playlist)',
path: [...root, 'downloads'],
section: 'downloads',
});
@@ -162,21 +162,21 @@ export function buildPortalRailLinks(
const primary: PortalRailLink[] = [
{
icon: 'list',
tooltip: 'All Channels',
tooltip: 'All channels (this playlist)',
path: [...root, 'all'],
exact: true,
section: 'all',
},
{
icon: 'folder',
tooltip: 'Groups',
tooltip: 'Groups (this playlist)',
path: [...root, 'groups'],
exact: true,
section: 'groups',
},
{
icon: 'star',
tooltip: 'Favorites',
tooltip: 'Favorites (this playlist)',
path: [...root, 'favorites'],
exact: true,
section: 'favorites',
@@ -10,6 +10,7 @@ export interface SettingsNavItem {
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
readonly activeSection = signal<string>('general');
readonly pendingScrollTarget = signal<string | null>(null);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
@@ -19,8 +20,18 @@ export class SettingsContextService {
this.activeSection.set(id);
}
navigateToSection(id: string): void {
this.activeSection.set(id);
this.pendingScrollTarget.set(id);
}
clearPendingScrollTarget(): void {
this.pendingScrollTarget.set(null);
}
reset(): void {
this.sections.set([]);
this.activeSection.set('general');
this.pendingScrollTarget.set(null);
}
}
@@ -38,12 +38,12 @@
}
</div>
} @else {
<app-playlist-error-view
[title]="'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.TITLE' | translate"
[description]="
'PORTALS.ERROR_VIEW.EMPTY_CATEGORY.DESCRIPTION' | translate
"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
<div class="category-empty-state">
<app-playlist-error-view
[title]="'No categories available'"
[showIllustration]="false"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
</div>
}
@@ -1,7 +1,9 @@
@use '../../../nav-list.scss';
:host {
display: block;
height: 100%;
min-height: 0;
overflow-y: auto;
flex-wrap: wrap;
gap: 10px;
@@ -30,9 +32,38 @@ mat-card-content {
padding: 12px;
}
.category-empty-state {
height: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(14px, 3vh, 28px) 12px clamp(20px, 4vh, 34px);
box-sizing: border-box;
app-playlist-error-view {
--error-empty-illustration-size: clamp(132px, 15vw, 180px);
--error-empty-max-width: min(270px, 100%);
--error-empty-title-size: clamp(1.15rem, 1.35vw, 1.4rem);
--error-empty-title-weight: 610;
--error-empty-title-spacing: -0.006em;
--error-empty-copy-gap: 4px;
--error-empty-description-opacity: 0.82;
}
}
@media (max-width: 800px) {
.category-item {
width: 100%;
margin: 4px;
}
.category-empty-state {
app-playlist-error-view {
--error-empty-illustration-size: 142px;
--error-empty-max-width: 230px;
--error-empty-title-size: 1.05rem;
--error-empty-title-weight: 600;
}
}
}
@@ -150,6 +150,28 @@ export class FavoritesComponent implements OnInit, OnDestroy {
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.xtreamStore.openPlayer(streamUrl, item.title, item.poster_url);
} else {
const routePlaylistId = this.route.snapshot.params['id'];
const hasGlobalContext = this.router.url.includes(
'/workspace/global-favorites'
);
const shouldNavigateAbsolute =
hasGlobalContext ||
(item.playlist_id &&
routePlaylistId &&
item.playlist_id !== routePlaylistId);
if (shouldNavigateAbsolute && item.playlist_id) {
this.router.navigate([
'/workspace',
'xtreams',
item.playlist_id,
type,
item.category_id,
item.xtream_id,
]);
return;
}
this.router.navigate(
['..', type, item.category_id, item.xtream_id],
{
@@ -1,22 +1,33 @@
<div class="expired-account">
@if (viewType() === 'ERROR') {
<img alt="Something went wrong" src="./assets/images/broken-tv.svg" />
} @else if (viewType() === 'EMPTY_CATEGORY') {
<img
alt="empty category"
class="empty-category-img"
src="./assets/images/empty-category.svg"
/>
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
<mat-icon class="icon">search</mat-icon>
<div
class="error-view"
[class.error-view--empty-category]="viewType() === 'EMPTY_CATEGORY'"
[class.error-view--text-only]="!showIllustration()"
>
@if (showIllustration()) {
@if (viewType() === 'ERROR') {
<img
alt="Something went wrong"
src="./assets/images/broken-tv.svg"
/>
} @else if (viewType() === 'EMPTY_CATEGORY') {
<img
alt="empty category"
class="error-view__illustration"
src="./assets/images/empty-category.svg"
/>
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
<mat-icon class="icon">search</mat-icon>
}
}
<div>
<h3>{{ title() }}</h3>
{{ description() }}
<div class="error-view__copy">
<h3 class="error-view__title">{{ title() }}</h3>
@if (description()) {
<p class="error-view__description">{{ description() }}</p>
}
</div>
@if (showActionButtons()) {
<div>
<div class="error-view__actions">
<button mat-stroked-button routerLink="/">
<mat-icon>home</mat-icon>
{{ 'PORTALS.ERROR_VIEW.HOME' | translate }}
@@ -1,12 +1,44 @@
.expired-account {
:host {
display: block;
width: 100%;
min-height: 0;
}
.error-view {
--error-view-illustration-size: var(
--error-empty-illustration-size,
clamp(170px, 28vw, 260px)
);
--error-view-copy-width: var(--error-empty-max-width, 420px);
--error-view-copy-gap: var(--error-empty-copy-gap, 8px);
--error-view-title-size: var(--error-empty-title-size, 1.55rem);
--error-view-title-weight: var(--error-empty-title-weight, 640);
--error-view-title-spacing: var(--error-empty-title-spacing, -0.012em);
--error-view-description-size: var(--error-empty-description-size, 0.98rem);
--error-view-description-line-height: var(
--error-empty-description-line-height,
1.4
);
--error-view-description-opacity: var(
--error-empty-description-opacity,
0.88
);
display: flex;
flex-direction: column;
padding: 10px;
align-items: center;
gap: 10px;
justify-content: center;
width: 100%;
min-height: 100%;
padding: clamp(12px, 2.2vw, 26px);
gap: 14px;
text-align: center;
box-sizing: border-box;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
img {
width: min(100%, var(--error-view-illustration-size));
height: auto;
filter: invert(80%);
}
}
@@ -17,13 +49,59 @@
width: 64px;
}
.empty-category-img {
max-width: 300px
.error-view--empty-category .error-view__illustration {
margin-top: clamp(0px, 1vh, 8px);
}
.error-view--text-only {
gap: 6px;
}
.error-view__copy {
max-width: var(--error-view-copy-width);
display: grid;
gap: var(--error-view-copy-gap);
}
.error-view__title {
margin: 0;
font-size: var(--error-view-title-size);
font-weight: var(--error-view-title-weight);
line-height: 1.12;
color: var(--app-heading-color, var(--mat-sys-on-surface));
letter-spacing: var(--error-view-title-spacing);
text-wrap: balance;
}
.error-view__description {
margin: 0;
font-size: var(--error-view-description-size);
line-height: var(--error-view-description-line-height);
color: inherit;
opacity: var(--error-view-description-opacity);
text-wrap: balance;
}
.error-view__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
@media (max-width: 1024px) {
.error-view {
--error-view-title-size: var(--error-empty-title-size, 1.4rem);
--error-view-description-size: var(
--error-empty-description-size,
0.92rem
);
}
}
::ng-deep {
.dark-theme {
.expired-account {
.error-view {
img {
filter: invert(10%) !important;
}
@@ -28,6 +28,7 @@ export class PlaylistErrorViewComponent {
);
readonly description = input<string>(undefined);
readonly showIllustration = input(true);
readonly showActionButtons = input(true);
readonly title = input<string>(undefined);
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
@@ -20,83 +20,27 @@
[itemSize]="68"
class="scroll-viewport-portals"
>
<div
<app-channel-list-item
*cdkVirtualFor="
let item of filteredChannels;
trackBy: trackBy;
templateCacheSize: 0
"
class="channel-item"
[class.active]="
[name]="item.title || item.name"
[logo]="item.stream_icon ?? item.poster_url"
[selected]="
xtreamStore.selectedItem()?.xtream_id === item.xtream_id
"
(click)="playClicked.emit(item)"
>
<img
class="stream-icon"
[src]="item.stream_icon ?? item.poster_url"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
alt="channel icon"
/>
<div class="channel-info">
<div class="title">{{ item.title }}</div>
@if (currentPrograms.get(item.xtream_id)) {
<div class="program-info">
<div class="current-program">
{{ currentPrograms.get(item.xtream_id) }}
</div>
@if (
currentProgramsProgress.has(item.xtream_id)
) {
<div class="progress-container">
<span>{{
programTimings.get(item.xtream_id)
?.start | date: 'HH:mm'
}}</span>
<div class="progress-bar">
<div
class="progress"
[style.width.%]="
currentProgramsProgress.get(
item.xtream_id
)
"
></div>
</div>
<span>{{
programTimings.get(item.xtream_id)
?.end | date: 'HH:mm'
}}</span>
</div>
}
</div>
} @else {
<div class="program-info no-epg-placeholder">
<div class="placeholder-bar"></div>
<div class="progress-container">
<span>--:--</span>
<div class="progress-bar"></div>
<span>--:--</span>
</div>
</div>
}
</div>
<button
class="favorite-btn"
(click)="toggleFavorite($event, item)"
>
<mat-icon>
{{
favorites.get(item.xtream_id)
? 'star'
: 'star_outline'
}}
</mat-icon>
</button>
</div>
[epgProgram]="epgPrograms.get(item.xtream_id) ?? null"
[progressPercentage]="
currentProgramsProgress.get(item.xtream_id) ?? 0
"
[showFavoriteButton]="true"
[showProgramInfoButton]="false"
[isFavorite]="favorites.get(item.xtream_id) ?? false"
(clicked)="playClicked.emit(item)"
(favoriteToggled)="toggleFavorite($event, item)"
/>
</cdk-virtual-scroll-viewport>
} @else {
<div class="empty-search-state">
@@ -1,5 +1,3 @@
@use '@angular/material' as mat;
:host {
display: block;
height: 100%;
@@ -11,7 +9,7 @@
.channels-list {
display: flex;
flex-direction: column;
height: calc(100% - 56px); // Account for search panel
height: calc(100% - 56px);
overflow: hidden;
width: 100%;
}
@@ -19,9 +17,9 @@
.scroll-viewport-portals {
height: 100%;
width: 100%;
padding: 4px 0;
padding: 4px 8px 12px;
box-sizing: border-box;
// Ensure the virtual scroll content respects parent width
::ng-deep .cdk-virtual-scroll-content-wrapper {
width: 100% !important;
max-width: 100% !important;
@@ -29,174 +27,6 @@
}
}
.channel-item {
display: flex;
align-items: center;
position: relative;
padding: 8px 8px 8px 12px;
margin: 2px 4px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
background: transparent;
color: var(--mat-sys-on-surface-variant);
height: 64px;
box-sizing: border-box;
gap: 8px;
width: calc(100% - 8px);
min-width: 0;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
.title {
color: var(--mat-sys-on-surface);
}
}
&.active {
background: linear-gradient(
135deg,
var(--app-selection-surface-strong),
var(--app-selection-surface)
) !important;
color: var(--app-selection-color) !important;
border-color: var(--app-selection-border) !important;
font-weight: 600;
box-shadow:
inset 0 0 0 1px var(--app-selection-border),
0 12px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
* {
color: var(--app-selection-color) !important;
}
.current-program,
.progress-container {
opacity: 0.92;
}
&::before {
content: '';
position: absolute;
inset-inline-start: 6px;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 999px;
background: var(--app-selection-color);
box-shadow: 0 0 10px var(--app-selection-glow);
}
}
}
.stream-icon {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent);
}
.channel-info {
flex: 1 1 0; // grow, shrink, basis=0 forces it to shrink
min-width: 0; // Critical for text truncation in flex
max-width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
.title {
font-weight: 600;
font-size: 14px;
transition: color 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-program {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
.progress-container {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
opacity: 0.6;
span {
flex-shrink: 0;
}
.progress-bar {
flex: 1;
height: 3px;
background: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
border-radius: 2px;
overflow: hidden;
min-width: 20px;
.progress {
height: 100%;
background: var(--mat-sys-primary);
transition: width 1s linear;
}
}
}
.no-epg-placeholder {
opacity: 0.5;
.placeholder-bar {
height: 10px;
width: 60%;
background: color-mix(in srgb, var(--mat-sys-on-surface) 10%, transparent);
border-radius: 2px;
margin: 2px 0;
}
}
}
.favorite-btn {
background: none;
border: none;
padding: 8px;
cursor: pointer;
color: inherit;
opacity: 0.6;
transition: opacity 0.2s ease;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
&:hover {
opacity: 1;
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
.search-panel {
padding: 10px 12px;
flex-shrink: 0;
@@ -211,8 +41,10 @@
width: 100%;
height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
border: 1px solid rgba(127, 127, 127, 0.22);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.08);
display: flex;
align-items: center;
gap: 8px;
@@ -225,15 +57,15 @@
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px rgba(var(--mat-sys-primary), 0.12);
border-color: var(--app-selection-border);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-selection-color) 16%, transparent);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
font-size: 18px;
opacity: 0.68;
flex-shrink: 0;
}
@@ -242,7 +74,7 @@
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
color: inherit;
font: inherit;
font-size: 0.875rem;
@@ -252,65 +84,11 @@
}
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
opacity: 0.62;
}
}
}
// Container queries for responsive behavior
@container (max-width: 350px) {
.channel-item {
gap: 6px;
padding: 6px;
}
.stream-icon {
width: 40px;
height: 40px;
}
.favorite-btn {
padding: 4px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
.progress-container {
display: none !important;
}
.no-epg-placeholder {
display: none !important;
}
}
@container (max-width: 300px) {
.stream-icon {
width: 32px;
height: 32px;
}
.favorite-btn {
display: none;
}
}
@container (max-width: 260px) {
.stream-icon {
display: none;
}
.channel-item {
height: 48px;
}
}
// ─── Empty State ──────────────────────────────────────────────────────────────
.empty-search-state {
display: flex;
flex-direction: column;
@@ -319,27 +97,27 @@
height: 100%;
padding: 32px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
animation: fadeIn 0.3s ease-out;
animation: fade-in 0.3s ease-out;
.empty-icon-wrapper {
width: 64px;
height: 64px;
border-radius: 20px;
background: var(--mat-sys-surface-container);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0)),
rgba(127, 127, 127, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
box-shadow:
box-shadow:
0 8px 24px -8px rgba(0, 0, 0, 0.1),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
mat-icon {
font-size: 32px;
width: 32px;
height: 32px;
color: var(--mat-sys-on-surface-variant);
font-size: 32px;
opacity: 0.8;
}
}
@@ -347,8 +125,7 @@
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 8px 0;
color: var(--mat-sys-on-surface);
margin: 0 0 8px;
letter-spacing: -0.01em;
}
@@ -361,11 +138,18 @@
}
}
@keyframes fadeIn {
@container (max-width: 350px) {
.scroll-viewport-portals {
padding-inline: 6px;
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -2,12 +2,12 @@ import {
CdkVirtualScrollViewport,
ScrollingModule,
} from '@angular/cdk/scrolling';
import { DatePipe } from '@angular/common';
import {
AfterViewInit,
ChangeDetectorRef,
Component,
computed,
effect,
inject,
input,
OnDestroy,
@@ -16,29 +16,24 @@ import {
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute } from '@angular/router';
import { FilterPipe } from '@iptvnator/pipes';
import { TranslatePipe } from '@ngx-translate/core';
import { Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { XtreamCategory, XtreamItem } from 'shared-interfaces';
import {
EpgItem,
EpgProgram,
XtreamCategory,
XtreamItem,
} from 'shared-interfaces';
import { ChannelListItemComponent } from 'components';
import { EpgQueueService } from '../services/epg-queue.service';
import { XtreamCredentials } from '../services/xtream-api.service';
import { FavoritesService } from '../services/favorites.service';
import { XtreamStore } from '../stores/xtream.store';
interface EpgProgram {
id: string;
title: string;
start: string;
end: string;
start_timestamp: string;
stop_timestamp: string;
}
type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
@Component({
@@ -46,12 +41,10 @@ type LiveChannelSortMode = 'server' | 'name-asc' | 'name-desc';
templateUrl: './portal-channels-list.component.html',
styleUrls: ['./portal-channels-list.component.scss'],
imports: [
DatePipe,
ChannelListItemComponent,
FilterPipe,
FormsModule,
MatFormFieldModule,
MatIcon,
MatInputModule,
ScrollingModule,
TranslatePipe,
],
@@ -87,15 +80,30 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
favorites = new Map<number, boolean>();
searchString = signal<string>('');
currentPrograms = new Map<number, string>();
epgPrograms = new Map<number, EpgProgram>();
currentProgramsProgress = new Map<number, number>();
programTimings = new Map<number, { start: number; end: number }>();
readonly viewport = viewChild(CdkVirtualScrollViewport);
private subscriptions = new Subscription();
constructor(private cdr: ChangeDetectorRef) {}
constructor(private cdr: ChangeDetectorRef) {
effect(() => {
const selectedItem = this.xtreamStore.selectedItem();
const epgItems = this.xtreamStore.epgItems();
if (!selectedItem?.xtream_id || epgItems.length === 0) {
return;
}
const previewProgram = this.pickPreviewProgram(epgItems);
if (!previewProgram) {
return;
}
this.applyProgram(selectedItem.xtream_id, previewProgram);
});
}
trackBy(_index: number, item: XtreamItem) {
return item.xtream_id;
@@ -121,13 +129,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
this.subscriptions.add(
this.epgQueueService.epgResult$.subscribe(
({ streamId, items }) => {
if (items && items.length > 0) {
this.currentPrograms.set(streamId, items[0].title);
this.updateProgramProgress(
streamId,
items[0] as unknown as EpgProgram
);
this.cdr.detectChanges();
const previewProgram = this.pickPreviewProgram(items);
if (previewProgram) {
this.applyProgram(streamId, previewProgram);
}
}
)
@@ -169,18 +173,12 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
// Apply cached results immediately
for (const channel of channels) {
const cached = this.epgQueueService.getCached(channel.xtream_id);
if (cached && cached.length > 0) {
if (!this.currentPrograms.has(channel.xtream_id)) {
this.currentPrograms.set(
channel.xtream_id,
cached[0].title
);
this.updateProgramProgress(
channel.xtream_id,
cached[0] as unknown as EpgProgram
);
const previewProgram = this.pickPreviewProgram(cached ?? []);
if (previewProgram) {
if (!this.epgPrograms.has(channel.xtream_id)) {
this.applyProgram(channel.xtream_id, previewProgram);
}
} else if (!this.currentPrograms.has(channel.xtream_id)) {
} else if (!this.epgPrograms.has(channel.xtream_id)) {
uncachedIds.push(channel.xtream_id);
}
}
@@ -190,10 +188,19 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
}
}
private updateProgramProgress(streamId: number, program: EpgProgram) {
const now = new Date().getTime() / 1000;
const start = parseInt(program.start_timestamp);
const end = parseInt(program.stop_timestamp);
private updateProgramProgress(
streamId: number,
program: EpgItem
) {
const now = Date.now();
const start = this.getProgramTimestampMs(
program.start,
program.start_timestamp
);
const end = this.getProgramTimestampMs(
program.stop ?? program.end,
program.stop_timestamp
);
if (now >= start && now <= end) {
const duration = end - start;
@@ -201,11 +208,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
const progress = (elapsed / duration) * 100;
this.currentProgramsProgress.set(streamId, progress);
this.programTimings.set(streamId, {
start: start * 1000,
end: end * 1000,
});
return;
}
this.currentProgramsProgress.delete(streamId);
}
isSelected(item: XtreamCategory): boolean {
@@ -234,4 +240,71 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
private applyProgram(streamId: number, program: EpgItem): void {
this.epgPrograms.set(streamId, this.toSharedEpgProgram(program));
this.updateProgramProgress(streamId, program);
this.cdr.detectChanges();
}
private pickPreviewProgram(items: EpgItem[]): EpgItem | null {
if (!items.length) {
return null;
}
const now = Date.now();
const normalizedItems = [...items].sort(
(a, b) =>
this.getProgramTimestampMs(a.start, a.start_timestamp) -
this.getProgramTimestampMs(b.start, b.start_timestamp)
);
const currentProgram = normalizedItems.find((item) => {
const start = this.getProgramTimestampMs(
item.start,
item.start_timestamp
);
const end = this.getProgramTimestampMs(
item.stop ?? item.end,
item.stop_timestamp
);
return now >= start && now <= end;
});
if (currentProgram) {
return currentProgram;
}
const nextProgram = normalizedItems.find((item) => {
return (
this.getProgramTimestampMs(item.start, item.start_timestamp) >
now
);
});
return nextProgram ?? normalizedItems[0];
}
private getProgramTimestampMs(
dateValue: string | undefined,
unixTimestampValue: string | undefined
): number {
const unixTimestamp = Number(unixTimestampValue);
if (Number.isFinite(unixTimestamp) && unixTimestamp > 0) {
return unixTimestamp * 1000;
}
return new Date(dateValue ?? '').getTime();
}
private toSharedEpgProgram(program: EpgItem): EpgProgram {
return {
start: program.start,
stop: program.stop ?? program.end,
channel: program.channel_id ?? program.id,
title: program.title,
desc: program.description ?? null,
category: null,
};
}
}
@@ -235,22 +235,25 @@ export class RecentlyViewedComponent implements OnDestroy {
if (source === 'stalker' && this.isGlobal) {
this.dialogRef?.close();
this.router.navigate(['/stalker', item.playlist_id, 'recent'], {
state: {
openRecentItem: item.stalker_item ?? {
id: item.id,
title: item.title,
name: item.title,
category_id:
item.type === 'movie'
? 'vod'
: item.type === 'live'
? 'itv'
: 'series',
cover: item.poster_url,
this.router.navigate(
['/workspace', 'stalker', item.playlist_id, 'recent'],
{
state: {
openRecentItem: item.stalker_item ?? {
id: item.id,
title: item.title,
name: item.title,
category_id:
item.type === 'movie'
? 'vod'
: item.type === 'live'
? 'itv'
: 'series',
cover: item.poster_url,
},
},
},
});
}
);
return;
}
@@ -261,7 +264,8 @@ export class RecentlyViewedComponent implements OnDestroy {
this.dialogRef?.close();
this.router.navigate([
'/xtreams',
'/workspace',
'xtreams',
item.playlist_id,
type,
item.category_id,
@@ -6,6 +6,6 @@ import { XtreamStore } from '../stores/xtream.store';
selector: 'app-global-search-results',
imports: [SearchResultsComponent],
providers: [XtreamStore],
template: '<app-search-results/>'
template: '<app-search-results/>',
})
export class GlobalSearchResultsComponent {}
@@ -0,0 +1,133 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { DatabaseService } from 'services';
import { SearchResultsComponent } from './search-results.component';
import { XtreamStore } from '../stores/xtream.store';
class MockXtreamStore {
readonly searchTerm = signal('');
readonly searchFilters = signal({
live: true,
movie: true,
series: true,
});
readonly searchResults = signal<any[]>([]);
readonly isSearching = signal(false);
setSearchTerm = jest.fn((term: string) => {
this.searchTerm.set(term);
});
setSearchFilters = jest.fn((filters: any) => {
this.searchFilters.set(filters);
});
updateSearchFilter = jest.fn();
setGlobalSearchResults = jest.fn((items: any[]) => {
this.searchResults.set(items);
});
setIsSearching = jest.fn((state: boolean) => {
this.isSearching.set(state);
});
resetSearchResults = jest.fn();
searchContent = jest.fn();
setSelectedContentType = jest.fn();
}
describe('SearchResultsComponent initialQuery contract', () => {
let routerNavigateMock: jest.Mock;
beforeEach(() => {
routerNavigateMock = jest.fn();
TestBed.configureTestingModule({
providers: [
{
provide: XtreamStore,
useClass: MockXtreamStore,
},
{
provide: Router,
useValue: {
navigate: routerNavigateMock,
},
},
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
},
queryParamMap: of(convertToParamMap({ q: '' })),
},
},
{
provide: DatabaseService,
useValue: {
globalSearchContent: jest.fn().mockResolvedValue([]),
},
},
],
});
});
it('applies initialQuery when opened as global search', () => {
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
initialQuery: 'matrix',
},
undefined as any
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
expect(store.setSearchTerm).toHaveBeenCalledWith('matrix');
expect(component.searchTerm()).toBe('matrix');
});
it('does not force search term when initialQuery is not provided', () => {
TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
undefined as any
)
);
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
expect(store.setSearchTerm).not.toHaveBeenCalled();
});
it('navigates global search selection to workspace xtream route', () => {
const component = TestBed.runInInjectionContext(
() =>
new SearchResultsComponent(
{
isGlobalSearch: true,
},
undefined as any
)
);
component.selectItem({
playlist_id: 'playlist-1',
type: 'movie',
category_id: 123,
xtream_id: 456,
} as any);
expect(routerNavigateMock).toHaveBeenCalledWith([
'/workspace',
'xtreams',
'playlist-1',
'vod',
123,
456,
]);
});
});
@@ -35,6 +35,7 @@ import { ContentType } from '../xtream-state';
interface SearchResultsData {
isGlobalSearch: boolean;
initialQuery?: string;
}
@Component({
@@ -128,6 +129,7 @@ export class SearchResultsComponent implements AfterViewInit {
@Optional() public dialogRef: MatDialogRef<SearchResultsComponent>
) {
this.isGlobalSearch = data?.isGlobalSearch || false;
const initialQuery = (data?.initialQuery ?? '').trim();
if (this.isGlobalSearch) {
const savedFilters = localStorage.getItem(
@@ -147,6 +149,10 @@ export class SearchResultsComponent implements AfterViewInit {
// Ignore malformed storage value and keep defaults.
}
}
if (initialQuery) {
this.xtreamStore.setSearchTerm(initialQuery);
}
}
effect((onCleanup) => {
@@ -254,7 +260,8 @@ export class SearchResultsComponent implements AfterViewInit {
this.dialogRef?.close();
const type = item.type === 'movie' ? 'vod' : item.type;
this.router.navigate([
'/xtreams',
'/workspace',
'xtreams',
item.playlist_id,
type,
item.category_id,
@@ -19,6 +19,7 @@ interface CacheEntry {
export class EpgQueueService implements OnDestroy {
private readonly apiService = inject(XtreamApiService);
private readonly logger = createLogger('EpgQueueService');
private readonly previewLimit = 3;
private readonly cache = new Map<number, CacheEntry>();
private queue: number[] = [];
@@ -109,12 +110,16 @@ export class EpgQueueService implements OnDestroy {
const items = await this.apiService.getShortEpg(
credentials,
streamId,
1
this.previewLimit
);
this.cache.set(streamId, {
data: items,
timestamp: Date.now(),
});
if (items.length > 0) {
this.cache.set(streamId, {
data: items,
timestamp: Date.now(),
});
} else {
this.cache.delete(streamId);
}
this.epgResult$.next({ streamId, items });
} catch (error) {
this.logger.error(
+4 -4
View File
@@ -88,16 +88,16 @@
opacity: 0.92;
}
&::before {
/* &::before {
content: '';
position: absolute;
inset-inline-start: 6px;
inset-inline-start: 2px;
top: 7px;
bottom: 7px;
width: 3px;
width: 2px;
border-radius: 999px;
background: var(--app-selection-color);
box-shadow: 0 0 10px var(--app-selection-glow);
}
} */
}
}