mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
style: enhance UI components with improved styles, search functionality, and error handling
This commit is contained in:
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"
|
||||
|
||||
+31
-12
@@ -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 /> -->
|
||||
|
||||
+96
-69
@@ -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>
|
||||
+157
-22
@@ -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 {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
+91
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user