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 {