style: enhance UI components with improved styles, search functionality, and error handling

This commit is contained in:
4gray committed 2026-02-26 00:45:58 +01:00
1 parent aa6a753aa5
commit 09efead104
15 files changed
+482 -156

No files matched your search

@@ -88,8 +88,8 @@ mat-card:hover {
position: absolute;
top: 6px;
right: 6px;
// Use a semi-transparent dark pill, not hardcoded green
background: rgba(0, 0, 0, 0.65);
// Use a solid or highly opaque dark pill to avoid expensive backdrop-filter
background: rgba(0, 0, 0, 0.85);
color: #4ade80; // green-400, readable across themes
font-size: 0.72rem;
font-weight: 600;
@@ -99,7 +99,6 @@ mat-card:hover {
align-items: center;
padding: 2px 5px;
gap: 2px;
backdrop-filter: blur(4px);
mat-icon {
font-size: 13px;
@@ -60,9 +60,7 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.85);
color: #fff;
&.live {
@@ -131,7 +131,7 @@
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
<button
<!-- <button
type="button"
class="header-shortcut"
mat-icon-button
@@ -142,7 +142,7 @@
aria-label="Toggle favorites only"
>
<mat-icon>favorite</mat-icon>
</button>
</button> -->
@if (canSort()) {
<button
type="button"
@@ -12,11 +12,13 @@
>
<div class="sidebar-header">
@if (selectedCategoryId()) {
<button mat-icon-button (click)="backToCategories()">
<!-- <button mat-icon-button (click)="backToCategories()">
<mat-icon>chevron_left</mat-icon>
</button>
</button> -->
<h2 class="category-title">
{{ selectedCategoryInfo()?.name }} ({{ selectedCategoryInfo()?.count }})
{{ selectedCategoryInfo()?.name }} ({{
selectedCategoryInfo()?.count
}})
</h2>
<button
mat-icon-button
@@ -27,19 +29,28 @@
<mat-icon>sort_by_alpha</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button mat-menu-item (click)="setLiveChannelSortMode('server')">
<button
mat-menu-item
(click)="setLiveChannelSortMode('server')"
>
<span>Server Order</span>
@if (liveChannelSortMode() === 'server') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-asc')">
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-asc')"
>
<span>Name A-Z</span>
@if (liveChannelSortMode() === 'name-asc') {
<mat-icon>check</mat-icon>
}
</button>
<button mat-menu-item (click)="setLiveChannelSortMode('name-desc')">
<button
mat-menu-item
(click)="setLiveChannelSortMode('name-desc')"
>
<span>Name Z-A</span>
@if (liveChannelSortMode() === 'name-desc') {
<mat-icon>check</mat-icon>
@@ -50,7 +61,11 @@
@if (!isWorkspaceLayout) {
<app-playlist-switcher
class="switcher"
[currentTitle]="currentPlaylist()?.name || currentPlaylist()?.title || 'Playlist'"
[currentTitle]="
currentPlaylist()?.name ||
currentPlaylist()?.title ||
'Playlist'
"
[subtitle]="'Xtream Code'"
/>
}
@@ -58,7 +73,9 @@
mat-icon-button
class="manage-categories-btn"
(click)="openCategoryManagement()"
[matTooltip]="'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate"
[matTooltip]="
'XTREAM.CATEGORY_MANAGEMENT.TITLE' | translate
"
>
<mat-icon>tune</mat-icon>
</button>
@@ -95,10 +112,12 @@
</div>
} @else {
<div class="no-channel-selected">
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
@if (!selectedCategoryId()) {
<button mat-icon-button disabled>
<mat-icon>category</mat-icon>
</button>
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
}
</div>
}
<!-- <app-mpv-player-bar /> -->
@@ -1,84 +1,111 @@
<div class="search-panel">
<mat-form-field class="search-bar" subscriptSizing="dynamic">
<mat-icon matPrefix>search</mat-icon>
<label class="search-field">
<mat-icon>search</mat-icon>
<input
matInput
spellcheck="false"
type="search"
spellcheck="false"
[placeholder]="'CHANNELS.SEARCH_CHANNEL' | translate"
[(ngModel)]="searchString"
/>
</mat-form-field>
</label>
</div>
<div class="channels-list">
<cdk-virtual-scroll-viewport
[itemSize]="68"
class="scroll-viewport-portals"
>
<div
*cdkVirtualFor="
let item of sortedChannels() | filterBy: searchString() : 'title';
trackBy: trackBy;
templateCacheSize: 0
"
class="channel-item"
[class.active]="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>
@if (
sortedChannels() | filterBy: searchString() : 'title';
as filteredChannels
) {
@if (filteredChannels.length > 0) {
<cdk-virtual-scroll-viewport
[itemSize]="68"
class="scroll-viewport-portals"
>
<div
*cdkVirtualFor="
let item of filteredChannels;
trackBy: trackBy;
templateCacheSize: 0
"
class="channel-item"
[class.active]="
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>
<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>
}
<button
class="favorite-btn"
(click)="toggleFavorite($event, item)"
>
<mat-icon>
{{
favorites.get(item.xtream_id)
? 'star'
: 'star_outline'
}}
</mat-icon>
</button>
</div>
</cdk-virtual-scroll-viewport>
} @else {
<div class="empty-search-state">
<div class="empty-icon-wrapper">
<mat-icon>search_off</mat-icon>
</div>
<h3>{{ 'CHANNELS.NO_CHANNELS_FOUND' | translate }}</h3>
<p>We couldn't find anything matching "{{ searchString() }}"</p>
</div>
<button
class="favorite-btn"
(click)="toggleFavorite($event, item)"
>
<mat-icon>
{{ favorites.get(item.xtream_id) ? 'star' : 'star_outline' }}
</mat-icon>
</button>
</div>
</cdk-virtual-scroll-viewport>
}
}
</div>
@@ -32,32 +32,68 @@
.channel-item {
display: flex;
align-items: center;
padding: 8px;
position: relative;
padding: 8px 8px 8px 12px;
margin: 2px 4px;
border-radius: 4px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
transition: background-color 0.2s ease;
background: transparent;
color: var(--mat-sys-on-surface-variant);
height: 64px;
box-sizing: border-box;
gap: 8px;
// Critical: use margin-based calc and ensure it doesn't overflow
width: calc(100% - 8px);
min-width: 0; // Allow shrinking below content size
overflow: hidden;
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-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent);
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
.title {
color: var(--mat-sys-primary);
color: var(--mat-sys-on-surface);
}
}
&.active {
background-color: color-mix(in srgb, var(--mat-sys-primary) 16%, transparent);
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);
.title {
color: var(--mat-sys-primary);
* {
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);
}
}
}
@@ -162,26 +198,63 @@
}
.search-panel {
padding: 8px 12px;
padding: 10px 12px;
flex-shrink: 0;
height: 56px;
box-sizing: border-box;
display: flex;
align-items: center;
}
.search-bar {
.search-field {
flex: 1;
width: 100%;
@include mat.form-field-density(-5);
}
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));
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
box-sizing: border-box;
cursor: text;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
::ng-deep {
.search-bar {
div {
border-radius: 20px;
}
&:focus-within {
border-color: var(--mat-sys-primary);
box-shadow: 0 0 0 3px rgba(var(--mat-sys-primary), 0.12);
}
div[matformfieldlineripple] {
display: none;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
font: inherit;
font-size: 0.875rem;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&::placeholder {
color: var(--mat-sys-on-surface-variant);
opacity: 0.7;
}
}
}
@@ -236,3 +309,65 @@
height: 48px;
}
}
// ─── Empty State ──────────────────────────────────────────────────────────────
.empty-search-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 32px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
animation: fadeIn 0.3s ease-out;
.empty-icon-wrapper {
width: 64px;
height: 64px;
border-radius: 20px;
background: var(--mat-sys-surface-container);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
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);
opacity: 0.8;
}
}
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 8px 0;
color: var(--mat-sys-on-surface);
letter-spacing: -0.01em;
}
p {
font-size: 0.875rem;
margin: 0;
opacity: 0.7;
line-height: 1.4;
max-width: 260px;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -30,7 +30,7 @@
top: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.85);
padding: 6px 8px;
font-size: 12px;
color: white;
@@ -39,7 +39,6 @@
text-overflow: ellipsis;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
backdrop-filter: blur(5px);
display: flex;
align-items: center;
gap: 4px;
@@ -324,13 +324,12 @@
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.75);
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
backdrop-filter: blur(4px);
z-index: 2;
}
@@ -440,13 +439,12 @@
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.85);
padding: 2px 6px;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
color: white;
backdrop-filter: blur(4px);
}
&__info {
+1
View File
@@ -236,6 +236,7 @@
"LOADING": "Loading channels...",
"LOADING_MORE": "Loading more...",
"NO_SEARCH_RESULTS": "No channels found",
"NO_CHANNELS_FOUND": "No channels found",
"TRY_DIFFERENT_SEARCH": "Try a different search term"
},
"TOP_MENU": {
+1
View File
@@ -47,6 +47,7 @@
text-overflow: ellipsis;
flex: 1;
min-width: 0;
padding-left: 10px;
}
.nav-item-meta {
@@ -21,7 +21,7 @@
width: '100%',
'border-radius': '8px',
'aspect-ratio': '2/3',
display: 'block'
display: 'block',
}"
/>
</div>
@@ -35,7 +35,7 @@
height: '40px',
width: '60%',
'margin-bottom': '16px',
display: 'block'
display: 'block',
}"
/>
@@ -46,7 +46,7 @@
[theme]="{
height: '24px',
width: '60px',
display: 'block'
display: 'block',
}"
/>
</div>
@@ -58,7 +58,7 @@
height: '16px',
'margin-bottom': '8px',
width: '100%',
display: 'block'
display: 'block',
}"
/>
@@ -69,7 +69,7 @@
[theme]="{
height: '40px',
width: '100px',
display: 'block'
display: 'block',
}"
/>
</div>
@@ -81,7 +81,7 @@
[theme]="{
height: '48px',
width: '140px',
display: 'block'
display: 'block',
}"
/>
</div>
@@ -91,13 +91,15 @@
<!-- Backdrop Image -->
<div
class="hero__backdrop"
[class.hero__backdrop--blurred]="!backdropUrl()"
[class.hero__backdrop--blurred]="
!backdropUrl() && posterUrl() && !posterError()
"
[style.backgroundImage]="
backdropUrl()
backdropUrl() && !posterError()
? 'url(' + backdropUrl() + ')'
: posterUrl()
: posterUrl() && !posterError()
? 'url(' + posterUrl() + ')'
: 'none'
: fallbackBackdropBackground()
"
></div>
@@ -108,24 +110,30 @@
<div class="hero__content">
<!-- Poster Card -->
<div class="poster">
@if (posterUrl()) {
@if (posterUrl() && !posterError()) {
<img
class="poster__image"
[src]="posterUrl()"
(error)="
$event.target.src =
'./assets/images/default-poster.png'
"
(error)="onPosterError()"
alt="Poster"
/>
} @else {
<div class="poster__placeholder">No Poster</div>
<div
class="poster__placeholder"
[style.backgroundImage]="fallbackPosterBackground()"
>
<mat-icon class="poster__placeholder-icon"
>movie</mat-icon
>
<span class="poster__placeholder-text">{{
formattedTitle()
}}</span>
</div>
}
</div>
<!-- Details -->
<div class="details">
<h1 class="details__title">{{ title() }}</h1>
<h1 class="details__title">{{ formattedTitle() }}</h1>
<!-- Tags Slot -->
<div class="details__tags">
@@ -233,11 +233,32 @@
border-radius: 8px;
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
box-shadow: var(--poster-shadow);
overflow: hidden;
&-icon {
font-size: 48px;
width: 48px;
height: 48px;
opacity: 0.5;
margin-bottom: 12px;
}
&-text {
font-size: 1rem;
font-weight: 500;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
padding: 16px;
}
}
}
@@ -257,6 +278,11 @@
margin: 0 0 16px 0;
letter-spacing: -0.02em;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__tags {
@@ -1,5 +1,4 @@
import { Component, input, output } from '@angular/core';
import { Component, computed, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
@@ -7,11 +6,7 @@ import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
@Component({
selector: 'app-content-hero',
standalone: true,
imports: [
MatIconModule,
MatButtonModule,
NgxSkeletonLoaderComponent
],
imports: [MatIconModule, MatButtonModule, NgxSkeletonLoaderComponent],
templateUrl: './content-hero.component.html',
styleUrls: ['./content-hero.component.scss'],
})
@@ -24,6 +19,40 @@ export class ContentHeroComponent {
readonly errorMessage = input<string>();
readonly backClicked = output<void>();
readonly posterError = signal(false);
onPosterError(): void {
this.posterError.set(true);
}
readonly formattedTitle = computed(() => {
const t = this.title();
if (!t) return '';
// Replace underscores with spaces for cleaner UX on slug/filename style titles
return t.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
});
private calculateHue(text: string): number {
if (!text) return 0;
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
}
readonly fallbackPosterBackground = computed(() => {
const hue = this.calculateHue(this.title() || 'placeholder');
const h2 = (hue + 40) % 360;
return `linear-gradient(135deg, hsl(${hue}, 40%, 25%) 0%, hsl(${h2}, 50%, 15%) 100%)`;
});
readonly fallbackBackdropBackground = computed(() => {
const hue = this.calculateHue(this.title() || 'placeholder');
const h2 = (hue + 60) % 360;
return `linear-gradient(135deg, hsl(${hue}, 50%, 15%) 0%, hsl(${h2}, 80%, 5%) 100%)`;
});
onBack(): void {
this.backClicked.emit();
@@ -25,38 +25,37 @@
/>
</mat-form-field>
</div>
<mat-selection-list [multiple]="false">
<div class="nav-list" style="height: calc(100% - 70px)">
<cdk-virtual-scroll-viewport
[itemSize]="56"
class="scroll-viewport-portals"
>
<mat-list-option
<div
class="nav-item"
*cdkVirtualFor="
let item of channels
| filterBy: searchString() : 'name';
trackBy: trackBy;
templateCacheSize: 0
"
[selected]="
[class.selected]="
item.stream_id === activeLiveStream?.stream_id
"
[value]="item"
(click)="itemClicked.emit(item)"
>
@if (item.stream_icon) {
<img
matListItemAvatar
class="stream-icon"
[src]="item.stream_icon"
alt="channel icon"
/>
}
<div class="title" matListItemTitle>
<div class="title">
{{ item.name }}
</div>
</mat-list-option>
</div>
</cdk-virtual-scroll-viewport>
</mat-selection-list>
</div>
</div>
</div>
@if (player === 'videojs' || player === 'html5') {
@@ -78,10 +78,6 @@
margin: 10px;
}
mat-list-option {
width: 100%;
}
::ng-deep {
.search-bar {
div {
@@ -89,3 +85,94 @@ mat-list-option {
}
}
}
.nav-list {
flex: 1;
overflow-y: hidden;
padding: 0 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
position: relative;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
text-align: left;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
box-sizing: border-box;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
.stream-icon {
width: 36px;
height: 36px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent);
}
span,
.title {
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
&.selected,
&.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;
}
&::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);
}
}
}