diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html
index ee6a402d9..f585dbfd1 100644
--- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html
+++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.html
@@ -46,61 +46,27 @@
-->
@for (item of itvChannels(); track item.id ?? $index) {
-
-
![]()
-
-
{{
- item.o_name || item.name
- }}
- @if (currentPrograms.get(item.id)) {
-
-
- {{ currentPrograms.get(item.id) }}
-
- @if (currentProgramsProgress.has(item.id)) {
-
-
{{
- programTimings.get(item.id)?.start
- | date: 'HH:mm'
- }}
-
-
{{
- programTimings.get(item.id)?.end
- | date: 'HH:mm'
- }}
-
- }
-
- }
-
-
-
+
}
@if (isLoadingMore()) {
diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss
index d60056f52..06f8d2a8c 100644
--- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss
+++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.scss
@@ -33,23 +33,38 @@
display: flex;
flex-direction: column;
overflow: hidden;
+ min-height: 0;
}
.video-player {
- flex: 1;
- height: 50%;
+ flex: 1 1 0;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
}
.epg {
- flex: 1;
- height: 50%;
+ flex: 1 1 0;
+ min-height: 0;
overflow: hidden;
}
+.video-player > app-web-player-view {
+ flex: 1;
+ min-height: 0;
+}
+
.epg-content {
height: 100%;
- overflow-y: auto;
- padding-bottom: 20px;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.epg-content > app-epg-view {
+ flex: 1;
+ min-height: 0;
}
.no-channel-selected {
@@ -62,7 +77,6 @@
gap: 10px;
}
-// Channel list
.search-panel {
padding: 8px 12px;
flex-shrink: 0;
@@ -91,125 +105,10 @@
flex: 1;
overflow-y: auto;
overflow-x: hidden;
- padding: 4px 0;
-}
-
-.channel-item {
- display: flex;
- align-items: center;
- padding: 8px;
- margin: 2px 4px;
- border-radius: 4px;
- cursor: pointer;
- transition: background-color 0.2s ease;
- min-height: 48px;
- box-sizing: border-box;
- gap: 8px;
- width: calc(100% - 8px);
- min-width: 0;
- overflow: hidden;
-
- &:hover {
- background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
-
- .title {
- color: var(--mat-sys-primary);
- }
- }
-
- &.active {
- background-color: color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
-
- .title {
- color: var(--mat-sys-primary);
- }
- }
-}
-
-.stream-icon {
- width: 32px;
- height: 32px;
- 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;
- min-width: 0;
- max-width: 100%;
+ padding: 4px 8px 12px;
display: flex;
flex-direction: column;
- 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;
- }
- }
- }
-}
-
-.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;
- }
+ gap: 2px;
}
.loading-indicator {
@@ -224,43 +123,9 @@
padding: 8px 0;
}
-// Container queries for responsive behavior
@container (max-width: 350px) {
- .channel-item {
- gap: 6px;
- padding: 6px;
- }
-
- .stream-icon {
- width: 28px;
- height: 28px;
- }
-
- .favorite-btn {
- padding: 4px;
-
- mat-icon {
- font-size: 18px;
- width: 18px;
- height: 18px;
- }
- }
-}
-
-@container (max-width: 300px) {
- .stream-icon {
- width: 24px;
- height: 24px;
- }
-
- .favorite-btn {
- display: none;
- }
-}
-
-@container (max-width: 260px) {
- .stream-icon {
- display: none;
+ .channels-list {
+ padding-inline: 6px;
}
}
diff --git a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
index efa45fb0d..770a3f052 100644
--- a/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
+++ b/apps/web/src/app/stalker/stalker-live-stream-layout/stalker-live-stream-layout.component.ts
@@ -11,7 +11,6 @@ import {
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -21,9 +20,13 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
-import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
-import { PlaylistsService } from 'services';
-import { EpgItem } from 'shared-interfaces';
+import {
+ ChannelListItemComponent,
+ PlaylistSwitcherComponent,
+ ResizableDirective,
+} from 'components';
+import { PlaylistsService, StalkerSessionService } from 'services';
+import { EpgItem, EpgProgram, PlaylistMeta } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { SettingsStore } from '../../services/settings-store.service';
import { PlayerService } from '../../services/player.service';
@@ -54,7 +57,7 @@ import {
],
imports: [
CategoryViewComponent,
- DatePipe,
+ ChannelListItemComponent,
EpgViewComponent,
FormsModule,
MatButton,
@@ -77,10 +80,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly settingsStore = inject(SettingsStore);
private readonly playlistService = inject(PlaylistsService);
+ private readonly stalkerSession = inject(StalkerSessionService);
private readonly playerService = inject(PlayerService);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerLiveStream');
+ private readonly stalkerMagUserAgent =
+ 'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
+ private readonly stalkerStreamUserAgent = 'KSPlayer';
/** Categories */
readonly categories = this.stalkerStore.getCategoryResource;
@@ -98,6 +105,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
/** Search */
readonly searchString = signal('');
+ readonly selectedChannelId = this.stalkerStore.selectedItvId;
+ protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId;
/** Player */
readonly player = this.settingsStore.player;
@@ -111,9 +120,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private epgChannelId: number | string | null = null;
/** Channel list EPG preview */
- readonly currentPrograms = new Map
();
+ readonly epgPreviewPrograms = new Map();
readonly currentProgramsProgress = new Map();
- readonly programTimings = new Map();
private readonly requestedEpgChannels = new Set();
private readonly cdr = inject(ChangeDetectorRef);
private readonly ngZone = inject(NgZone);
@@ -255,6 +263,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.stalkerStore.setSelectedCategory(null);
}
+ isSelectedChannel(item: StalkerItvChannel): boolean {
+ return (
+ this.selectedChannelId() ===
+ this.normalizeStalkerEntityId(item.id)
+ );
+ }
+
async playChannel(item: StalkerItvChannel) {
this.stalkerStore.setSelectedItem(item);
@@ -275,15 +290,36 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
if (isEmbeddedPlayer) {
this.streamUrl = url;
} else {
+ const playlist = this.currentPlaylist();
+ const portalOrigin = this.getPortalOrigin(playlist);
+ const crossOriginStream = this.isCrossOriginStream(
+ playlist,
+ url
+ );
+ const playbackHeaders =
+ this.buildExternalPlaybackHeaders(playlist, url);
+ const playbackUserAgent =
+ playbackHeaders['User-Agent'] ||
+ playlist?.userAgent ||
+ this.stalkerMagUserAgent;
+ const playbackReferer = crossOriginStream
+ ? undefined
+ : playlist?.referrer || portalOrigin;
+ const playbackOrigin = crossOriginStream
+ ? undefined
+ : playlist?.origin || portalOrigin;
this.playerService.openPlayer(
url,
item.o_name || item.name,
item.logo,
true,
true,
- this.currentPlaylist()?.userAgent,
- this.currentPlaylist()?.referrer,
- this.currentPlaylist()?.origin
+ playbackUserAgent,
+ playbackReferer,
+ playbackOrigin,
+ undefined,
+ undefined,
+ playbackHeaders
);
}
@@ -404,7 +440,10 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
if (items.length > 0) {
const program = items[0];
const id = normalizeStalkerEntityId(channelId);
- this.currentPrograms.set(id, program.title);
+ this.epgPreviewPrograms.set(
+ id,
+ this.toPreviewProgram(program, id)
+ );
const now = Date.now() / 1000;
const start = parseInt(program.start_timestamp, 10);
@@ -414,10 +453,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const progress =
((now - start) / (end - start)) * 100;
this.currentProgramsProgress.set(id, progress);
- this.programTimings.set(id, {
- start: start * 1000,
- end: end * 1000,
- });
+ } else {
+ this.currentProgramsProgress.delete(id);
}
}
} catch {
@@ -426,9 +463,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
private clearEpgPreviewMaps() {
- this.currentPrograms.clear();
+ this.epgPreviewPrograms.clear();
this.currentProgramsProgress.clear();
- this.programTimings.clear();
this.requestedEpgChannels.clear();
}
@@ -472,6 +508,105 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
}
+ private getPortalOrigin(
+ playlist: PlaylistMeta | undefined | null
+ ): string | undefined {
+ const portalUrl = playlist?.portalUrl;
+ if (!portalUrl) return undefined;
+ try {
+ return new URL(portalUrl).origin;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private buildExternalPlaybackHeaders(
+ playlist: PlaylistMeta | undefined | null,
+ streamUrl?: string
+ ): Record {
+ if (!playlist?.macAddress) {
+ return {};
+ }
+
+ if (this.isCrossOriginStream(playlist, streamUrl)) {
+ return {
+ 'User-Agent': this.stalkerStreamUserAgent,
+ Accept: '*/*',
+ Range: 'bytes=0-',
+ Connection: 'keep-alive',
+ 'Icy-MetaData': '1',
+ };
+ }
+
+ const cookieParts = [
+ `mac=${playlist.macAddress}`,
+ 'stb_lang=en_US@rg=dezzzz',
+ 'timezone=Europe/Berlin',
+ ];
+ if (playlist.stalkerSerialNumber) {
+ cookieParts.push(
+ `__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1`
+ );
+ }
+
+ const headers: Record = {
+ Cookie: cookieParts.join('; '),
+ 'X-User-Agent': this.stalkerMagUserAgent,
+ SN: playlist.stalkerSerialNumber || '',
+ };
+
+ const token = this.stalkerSession.getCachedToken(playlist._id);
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ const origin = this.getPortalOrigin(playlist);
+ if (origin) {
+ headers['Origin'] = origin;
+ headers['Referer'] = origin;
+ }
+
+ return Object.entries(headers).reduce>(
+ (acc, [name, value]) => {
+ if (value?.trim()) {
+ acc[name] = value;
+ }
+ return acc;
+ },
+ {}
+ );
+ }
+
+ private isCrossOriginStream(
+ playlist: PlaylistMeta | undefined | null,
+ streamUrl?: string
+ ): boolean {
+ const portalOrigin = this.getPortalOrigin(playlist);
+ if (!portalOrigin || !streamUrl) {
+ return false;
+ }
+
+ try {
+ return new URL(streamUrl).origin !== portalOrigin;
+ } catch {
+ return false;
+ }
+ }
+
+ private toPreviewProgram(
+ item: EpgItem,
+ channelId: string | number
+ ): EpgProgram {
+ return {
+ start: item.start,
+ stop: item.stop || item.end,
+ channel: String(channelId),
+ title: item.title,
+ desc: item.description || null,
+ category: null,
+ };
+ }
+
private handleRemoteChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.stalkerStore.selectedItem();
if (!activeItem?.id) {
diff --git a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss
index f8b2963b5..7450a5b04 100644
--- a/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss
+++ b/apps/web/src/app/xtream-electron/live-stream-layout/live-stream-layout.component.scss
@@ -39,23 +39,38 @@
display: flex;
flex-direction: column;
overflow: hidden;
+ min-height: 0;
}
.video-player {
- flex: 1;
- height: 50%;
+ flex: 1 1 0;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
}
.epg {
- flex: 1;
- height: 50%;
+ flex: 1 1 0;
+ min-height: 0;
overflow: hidden;
}
+.video-player > app-web-player-view {
+ flex: 1;
+ min-height: 0;
+}
+
.epg-content {
height: 100%;
- overflow-y: auto;
- padding-bottom: 20px;
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.epg-content > app-epg-view {
+ flex: 1;
+ min-height: 0;
}
.sidebar-header {
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
index 396d95ee0..9a90a725d 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
@@ -12,78 +12,84 @@
drag_indicator
}
- @if (logo()) {
-
![Channel Logo]()
- }
+
+ @if (showLogoFallback()) {
+
live_tv
+ }
+ @if (logo()) {
+
![Channel Logo]()
+ }
+
{{ name() }}
@if (showEpg()) {
-
-
- @if (epgProgram(); as program) {
-
- {{ program.title }}
-
-
-
{{ formatTime(program.start) }} -
- {{ formatTime(program.stop) }}
-
-
- } @else {
-
- {{ 'EPG.NO_PROGRAM_INFO' | translate }}
-
- }
+ @if (epgProgram(); as program) {
+
+ {{ program.title }}
-
+
+
{{
+ formatTime(program.start)
+ }}
+
+
{{
+ formatTime(program.stop)
+ }}
+
+ } @else {
+
+ {{ 'EPG.NO_PROGRAM_INFO' | translate }}
+
+ }
}
-
-
- @if (showEpg() && epgProgram(); as program) {
-
- }
- @if (showFavoriteButton()) {
-
- }
-
+ @if (
+ (showProgramInfoButton() && showEpg() && epgProgram()) ||
+ showFavoriteButton()
+ ) {
+
+ @if (showProgramInfoButton() && showEpg() && epgProgram(); as program) {
+
+ }
+ @if (showFavoriteButton()) {
+
+ }
+
+ }
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss
index 8f131b5ae..82ba34e29 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss
@@ -1,295 +1,301 @@
-// Main channel list item container
+:host {
+ display: block;
+ width: 100%;
+ container-type: inline-size;
+}
+
.channel-list-item {
width: 100%;
+ min-height: 68px;
display: flex;
align-items: center;
- height: 68px;
- padding: 8px 12px;
- border-radius: 4px;
- cursor: pointer;
- transition: background-color 0.2s ease;
gap: 12px;
+ padding: 8px 10px 8px 12px;
+ border: 1px solid transparent;
+ border-radius: 12px;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
box-sizing: border-box;
- min-width: 0;
overflow: hidden;
+ transition:
+ background 0.15s ease,
+ border-color 0.15s ease,
+ box-shadow 0.18s ease,
+ transform 0.18s ease,
+ color 0.15s ease;
&:hover {
- background-color: rgba(0, 0, 0, 0.04);
+ background: color-mix(in srgb, currentColor 8%, transparent);
}
&.active {
- background-color: rgba(0, 0, 0, 0.08);
+ background: linear-gradient(
+ 135deg,
+ var(--app-selection-surface-strong),
+ var(--app-selection-surface)
+ );
+ border-color: var(--app-selection-border);
+ color: var(--app-selection-color);
+ box-shadow:
+ inset 0 0 0 1px var(--app-selection-border),
+ 0 12px 20px -18px var(--app-selection-glow);
+ transform: translateY(-1px);
}
- // Compact mode when EPG is not shown
&.compact {
- height: 48px;
- padding: 6px 12px;
+ min-height: 52px;
+ padding-block: 7px;
}
}
-// Dark theme support
-::ng-deep .dark-theme {
- .channel-list-item {
- &:hover {
- background-color: rgba(255, 255, 255, 0.04);
- }
-
- &.active {
- background-color: rgba(255, 255, 255, 0.08);
- }
- }
-}
-
-// Drag handle icon
.drag-icon {
+ flex-shrink: 0;
cursor: move;
- margin-right: 4px;
- opacity: 0.5;
+ opacity: 0.46;
}
-// Main content area
.channel-content {
+ min-width: 0;
+ flex: 1;
display: flex;
align-items: center;
- flex: 1;
gap: 12px;
- min-width: 0; // Allow text truncation
}
-// Channel logo
-.channel-logo {
- width: 42px;
- height: 42px;
- border-radius: 4px;
- object-fit: contain;
+.channel-logo-shell {
+ position: relative;
+ width: 44px;
+ height: 44px;
flex-shrink: 0;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0)),
+ rgba(127, 127, 127, 0.12);
+ box-shadow:
+ inset 0 0 0 1px rgba(255, 255, 255, 0.08),
+ inset 0 1px 1px rgba(0, 0, 0, 0.15);
- // Smaller logo in compact mode
.compact & {
- width: 32px;
- height: 32px;
+ width: 38px;
+ height: 38px;
}
}
-// Channel details (name + EPG)
-.channel-details {
- display: flex;
- flex-direction: column;
- gap: 2px;
- flex: 1;
- min-width: 0; // Allow text truncation
- overflow: visible;
-}
-
-// Channel name
-.channel-name {
- font-family: 'DM Sans', sans-serif;
- font-weight: 500;
- font-size: 0.9rem;
- line-height: 1.3;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
- max-width: 100%;
-}
-
-// EPG container - holds EPG content and info button
-.epg-container {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
- overflow: visible;
-}
-
-// EPG content area
-.epg-content {
- display: flex;
- flex-direction: column;
- gap: 2px;
- flex: 1;
- min-width: 0;
- overflow: hidden; // Allow text truncation within content
-}
-
-// EPG title (program name)
-.epg-title {
- font-family:
- -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
- Arial, sans-serif;
- font-size: 0.8rem;
- font-weight: 500;
- opacity: 0.85;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- line-height: 1.2;
- min-width: 0;
- max-width: 100%;
-}
-
-// EPG timeline container
-.epg-timeline {
- display: flex;
- flex-direction: column;
- gap: 2px;
- min-width: 0;
- max-width: 100%;
-}
-
-// EPG time display
-.epg-time {
- font-family: 'DM Sans', sans-serif;
- font-size: 0.7rem;
- opacity: 0.6;
- letter-spacing: 0.02em;
- line-height: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- min-width: 0;
- max-width: 100%;
-}
-
-// EPG placeholder (when no data available)
-.epg-placeholder {
- font-family: 'DM Sans', sans-serif;
- font-size: 0.75rem;
+.channel-logo-fallback {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 18px;
+ height: 18px;
+ font-size: 18px;
opacity: 0.5;
- font-style: italic;
+}
+
+.channel-logo {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ padding: 6px;
+ box-sizing: border-box;
+}
+
+.channel-details {
+ min-width: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 3px;
+}
+
+.channel-name {
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- min-width: 0;
- max-width: 100%;
+ font-size: 0.96rem;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ line-height: 1.2;
+}
+
+.epg-title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.82rem;
+ line-height: 1.2;
+ opacity: 0.84;
+}
+
+.epg-timeline {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+}
+
+.epg-time {
+ font-size: 0.75rem;
+ line-height: 1;
+ opacity: 0.72;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
}
-// Progress track
.epg-progress-track {
position: relative;
width: 100%;
- height: 3px;
- background: rgba(128, 128, 128, 0.2);
- border-radius: 2px;
+ height: 6px;
overflow: hidden;
+ border-radius: 999px;
+ background-color: rgba(127, 127, 127, 0.28);
+ background-image: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.12),
+ rgba(255, 255, 255, 0)
+ );
+ box-shadow:
+ inset 0 0 0 1px rgba(255, 255, 255, 0.12),
+ inset 0 1px 1px rgba(0, 0, 0, 0.18);
}
-// Progress fill
.epg-progress-fill {
position: absolute;
- left: 0;
- top: 0;
- height: 100%;
- background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 50%, #ec4899 100%);
- border-radius: 2px;
- transition: width 0.5s ease-out;
- box-shadow: 0 0 6px rgba(124, 58, 237, 0.4);
+ inset-block: 0;
+ inset-inline-start: 0;
+ border-radius: inherit;
+ background-color: var(--app-selection-color);
+ background-image: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.18),
+ rgba(255, 255, 255, 0)
+ );
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--app-selection-border) 82%, transparent),
+ 0 0 10px -2px var(--app-selection-glow);
+ transition: width 0.35s ease;
}
-// Favorite button
-.favorite-button {
- flex-shrink: 0;
+.epg-placeholder {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.78rem;
+ line-height: 1.2;
+ opacity: 0.52;
}
-// Program info button
-.program-info-button {
- flex-shrink: 0;
- opacity: 0.7;
-
- &:hover {
- opacity: 1;
- }
-}
-
-// Action buttons column (info + favorite stacked vertically)
.action-buttons {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
flex-shrink: 0;
- margin-left: auto;
- gap: 0;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-inline-start: auto;
button {
- width: 32px;
- height: 32px;
- line-height: 32px;
- padding: 0;
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ color: inherit;
+ opacity: 0.76;
+ transition:
+ opacity 0.15s ease,
+ background-color 0.15s ease,
+ transform 0.15s ease;
- .mat-icon {
- font-size: 20px;
- width: 20px;
- height: 20px;
- line-height: 20px;
+ &:hover {
+ opacity: 1;
+ background: color-mix(in srgb, currentColor 10%, transparent);
+ transform: translateY(-1px);
}
}
+
+ mat-icon {
+ width: 20px;
+ height: 20px;
+ font-size: 20px;
+ }
}
-// ============================================================================
-// Container queries for responsive sidebar resizing
-// ============================================================================
+.channel-list-item.active {
+ .epg-title,
+ .epg-time,
+ .epg-placeholder,
+ .drag-icon,
+ .action-buttons button,
+ .action-buttons mat-icon {
+ color: inherit;
+ }
+}
-@container (max-width: 350px) {
+@container (max-width: 360px) {
.channel-list-item {
- gap: 8px;
- padding: 6px 8px;
+ gap: 10px;
+ padding-inline: 10px 8px;
}
- .channel-logo {
- width: 36px;
- height: 36px;
+ .channel-logo-shell {
+ width: 40px;
+ height: 40px;
}
- // Hide EPG timeline (progress bar + times) but keep program title
.epg-timeline {
- display: none;
+ gap: 8px;
}
- .epg-placeholder {
- display: none;
+ .action-buttons {
+ gap: 0;
+ }
+}
+
+@container (max-width: 310px) {
+ .epg-timeline {
+ grid-template-columns: 1fr;
+ gap: 5px;
}
- // Hide info button, keep only favorite
+ .epg-time:last-child {
+ display: none;
+ }
+}
+
+@container (max-width: 270px) {
+ .channel-list-item {
+ min-height: 52px;
+ gap: 8px;
+ padding-inline: 8px 6px;
+ }
+
+ .channel-logo-shell {
+ width: 34px;
+ height: 34px;
+ }
+
+ .epg-title,
+ .epg-timeline,
+ .epg-placeholder,
.program-info-button {
display: none;
}
}
-@container (max-width: 280px) {
- .channel-logo {
- width: 28px;
- height: 28px;
- }
-
- // Hide all EPG content
- .epg-container {
- display: none;
- }
-
- .channel-list-item {
- height: 48px;
-
- &.compact {
- height: 40px;
- }
- }
-
- // Hide action buttons
+@container (max-width: 220px) {
+ .channel-logo-shell,
.action-buttons {
display: none;
}
-}
-@container (max-width: 200px) {
- .channel-logo {
- display: none;
- }
-
- .channel-list-item {
- gap: 4px;
- padding: 4px 6px;
+ .channel-content {
+ gap: 0;
}
}
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts
index d1f9889d9..09463e600 100644
--- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts
+++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts
@@ -3,9 +3,11 @@ import { NgStyle } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
+ effect,
inject,
input,
output,
+ signal,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -31,11 +33,13 @@ import { EpgItemDescriptionComponent } from '../../epg-list/epg-item-description
})
export class ChannelListItemComponent {
private readonly dialog = inject(MatDialog);
+ private readonly logoFailed = signal(false);
readonly isDraggable = input(false);
- readonly logo = input.required();
+ readonly logo = input('');
readonly name = input('');
readonly showFavoriteButton = input(false);
+ readonly showProgramInfoButton = input(true);
readonly isFavorite = input(false);
readonly selected = input(false);
readonly showEpg = input(true);
@@ -46,6 +50,13 @@ export class ChannelListItemComponent {
readonly clicked = output();
readonly favoriteToggled = output();
+ constructor() {
+ effect(() => {
+ this.logo();
+ this.logoFailed.set(false);
+ });
+ }
+
/**
* Formats time for display (HH:mm)
*/
@@ -69,4 +80,21 @@ export class ChannelListItemComponent {
data: program,
});
}
+
+ onFavoriteClick(event: MouseEvent): void {
+ event.stopPropagation();
+ this.favoriteToggled.emit(event);
+ }
+
+ showLogoFallback(): boolean {
+ return !this.logo() || this.logoFailed();
+ }
+
+ onLogoError(event: Event): void {
+ this.logoFailed.set(true);
+ (event.target as HTMLImageElement | null)?.style.setProperty(
+ 'display',
+ 'none'
+ );
+ }
}
diff --git a/libs/ui/components/src/lib/vjs-player/vjs-player.component.scss b/libs/ui/components/src/lib/vjs-player/vjs-player.component.scss
index 89e7e58b5..99c83a210 100644
--- a/libs/ui/components/src/lib/vjs-player/vjs-player.component.scss
+++ b/libs/ui/components/src/lib/vjs-player/vjs-player.component.scss
@@ -1,7 +1,15 @@
+:host {
+ display: block;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+}
+
.video-js {
position: relative !important;
width: 100% !important;
- height: calc(100vh - 65px);
+ height: 100% !important;
}
/* Hide the captions settings item from the captions menu. */
@@ -16,9 +24,3 @@
.video-js .vjs-menu-button-popup .vjs-menu {
z-index: 10;
}
-
-@media only screen and (max-width: 599px) {
- .video-js {
- height: calc(50vh - 65px) !important;
- }
-}
diff --git a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html
index 0c904885c..422853cc7 100644
--- a/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html
+++ b/libs/ui/shared-portals/src/lib/epg-view/epg-view.component.html
@@ -1,37 +1,49 @@
{{ 'EPG.TITLE' | translate }}
-
+
+
@for (item of epgItems; track $index) {
-
- {{ item.title }}
-
- {{ item.description }}
-
-
- {{ item.start | date: 'HH:mm' }} -
- {{ item.stop ?? item.end | date: 'HH:mm (dd.MM.yyyy)' }}
-
-
-