mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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],
|
||||
{
|
||||
|
||||
+26
-15
@@ -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 }}
|
||||
|
||||
+84
-6
@@ -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'>(
|
||||
|
||||
+14
-70
@@ -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">
|
||||
|
||||
+28
-244
@@ -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);
|
||||
|
||||
+118
-45
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
} */
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user