mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #754 from 4gray/feat/xtream-search-and-ui-improvements
Feat/xtream-search-and-ui-improvements
This commit is contained in:
30 files changed
+1027
-809
No files matched your search
@@ -34,7 +34,7 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build Backend
|
||||
run: npx nx build electron-backend
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<div class="content-card" (click)="onCardClick()">
|
||||
<div class="thumbnail-wrapper">
|
||||
@if (showRemoveButton()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
class="remove-button"
|
||||
[matTooltip]="removeTooltip()"
|
||||
(click)="onRemoveClick($event)"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (posterUrl()) {
|
||||
<img
|
||||
[src]="posterUrl()"
|
||||
[alt]="title()"
|
||||
(error)="onImageError($event)"
|
||||
class="poster"
|
||||
/>
|
||||
} @else if (showPlaceholder()) {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
|
||||
</div>
|
||||
} @else {
|
||||
<img
|
||||
src="./assets/images/default-poster.png"
|
||||
[alt]="title()"
|
||||
class="poster"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (type()) {
|
||||
<span class="type-badge" [class]="type()">
|
||||
{{ type() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-info">
|
||||
<h3>{{ title() }}</h3>
|
||||
@if (date()) {
|
||||
<p class="date-info">
|
||||
{{ date() | date: 'd MMM, HH:mm' }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
@use '../../styles/content-grid' as grid;
|
||||
|
||||
.content-card {
|
||||
@include grid.content-card;
|
||||
|
||||
.thumbnail-wrapper {
|
||||
@include grid.thumbnail-wrapper;
|
||||
|
||||
.remove-button {
|
||||
@include grid.remove-button;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
@include grid.type-badge;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
@include grid.card-info;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-card',
|
||||
standalone: true,
|
||||
imports: [DatePipe, MatIcon, MatIconButton, MatTooltip],
|
||||
templateUrl: './content-card.component.html',
|
||||
styleUrl: './content-card.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContentCardComponent {
|
||||
/** URL of the poster image */
|
||||
readonly posterUrl = input<string>();
|
||||
|
||||
/** Title to display */
|
||||
readonly title = input.required<string>();
|
||||
|
||||
/** Content type (live, movie, series) */
|
||||
readonly type = input<string>();
|
||||
|
||||
/** Optional date to display (Date, string, or timestamp number) */
|
||||
readonly date = input<Date | string | number>();
|
||||
|
||||
/** Whether to show the remove button */
|
||||
readonly showRemoveButton = input<boolean>(false);
|
||||
|
||||
/** Tooltip text for the remove button */
|
||||
readonly removeTooltip = input<string>('Remove');
|
||||
|
||||
/** Whether to show placeholder when no poster */
|
||||
readonly showPlaceholder = input<boolean>(true);
|
||||
|
||||
/** Emitted when the card is clicked */
|
||||
readonly cardClick = output<void>();
|
||||
|
||||
/** Emitted when the remove button is clicked */
|
||||
readonly remove = output<void>();
|
||||
|
||||
/** Get the icon for the placeholder based on type */
|
||||
getPlaceholderIcon(): string {
|
||||
switch (this.type()) {
|
||||
case 'live':
|
||||
return 'live_tv';
|
||||
case 'series':
|
||||
return 'tv';
|
||||
default:
|
||||
return 'movie';
|
||||
}
|
||||
}
|
||||
|
||||
onCardClick(): void {
|
||||
this.cardClick.emit();
|
||||
}
|
||||
|
||||
onRemoveClick(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.remove.emit();
|
||||
}
|
||||
|
||||
onImageError(event: Event): void {
|
||||
(event.target as HTMLImageElement).src =
|
||||
'./assets/images/default-poster.png';
|
||||
}
|
||||
}
|
||||
+10
-40
@@ -38,46 +38,16 @@
|
||||
@if (favorites().length) {
|
||||
<div class="items-grid">
|
||||
@for (item of favorites(); track $index) {
|
||||
<mat-card class="item-card" (click)="openFavorite(item)">
|
||||
<img
|
||||
[src]="
|
||||
item.poster_url ||
|
||||
item.cover ||
|
||||
'assets/default-' +
|
||||
selectedCategoryId() +
|
||||
'.png'
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
[alt]="item.title"
|
||||
class="item-image"
|
||||
/>
|
||||
<mat-card-content>
|
||||
<h3>
|
||||
{{ item?.title || item?.details?.info?.name }}
|
||||
</h3>
|
||||
<p class="added-date">
|
||||
Added:
|
||||
{{ item?.added_at | date: 'd MMMM, y, HH:MM' }}
|
||||
</p>
|
||||
</mat-card-content>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
class="favorite-button"
|
||||
[matTooltip]="
|
||||
'CHANNELS.REMOVE_FAVORITE' | translate
|
||||
"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
removeFromFavorites(item)
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</mat-card>
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url || item.cover"
|
||||
[title]="item?.title || item?.details?.info?.name || 'Unknown'"
|
||||
[type]="item.type"
|
||||
[date]="item.added_at"
|
||||
[showRemoveButton]="true"
|
||||
[removeTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
|
||||
(cardClick)="openFavorite(item)"
|
||||
(remove)="removeFromFavorites(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
|
||||
+2
-54
@@ -1,4 +1,5 @@
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../../shared/styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
@@ -47,66 +48,13 @@
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
@include grid.content-grid(140px, 20px);
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.item-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.favorite-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 12px;
|
||||
flex-grow: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
max-height: 2.4em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.added-date {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
|
||||
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
|
||||
import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-favorites-layout',
|
||||
@@ -17,11 +14,8 @@ import { CategoryViewComponent } from '../../../xtream-tauri/category-view/categ
|
||||
],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
DatePipe,
|
||||
MatCardModule,
|
||||
ContentCardComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
PlaylistSwitcherComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './content-card/content-card.component';
|
||||
export * from './header/header.component';
|
||||
@@ -0,0 +1,71 @@
|
||||
<div class="search-page">
|
||||
<!-- Inline details slot (for stalker) - shown above everything when active -->
|
||||
<ng-content select="[details]" />
|
||||
|
||||
<div class="search-content">
|
||||
<div class="header sticky-header">
|
||||
<div class="header-top">
|
||||
<div class="header-left">
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
|
||||
<!-- Filters slot -->
|
||||
<div class="type-filters">
|
||||
<ng-content select="[filters]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@if (showResultsCount() && resultsCount() > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ resultsCount() }} results
|
||||
</div>
|
||||
}
|
||||
@if (showCloseButton()) {
|
||||
<button mat-icon-button (click)="onCloseClick()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<!-- Extra header actions slot -->
|
||||
<ng-content select="[header-actions]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-input-container">
|
||||
<app-search-form
|
||||
[searchTerm]="searchTerm()"
|
||||
(searchTermChange)="onSearchTermChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-container">
|
||||
@if (isLoading()) {
|
||||
<div class="loading-state">
|
||||
<mat-spinner diameter="48" />
|
||||
</div>
|
||||
} @else if (showResults) {
|
||||
<!-- Results slot -->
|
||||
<ng-content select="[results]" />
|
||||
} @else if (showNoResults) {
|
||||
<div class="no-items">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">No results found for "{{ searchTerm() }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (showInitialState) {
|
||||
<div class="no-items initial-state">
|
||||
<mat-icon>search</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Custom empty state slot (optional override) -->
|
||||
<ng-content select="[empty]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,165 @@
|
||||
@use '../../styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--background, var(--mat-sidenav-content-background-color, #fff));
|
||||
padding: 20px 0 10px 0;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
|
||||
::ng-deep mat-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.results-count {
|
||||
font-size: 0.85rem;
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.results-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.no-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-items-content {
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-term-note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.initial-state {
|
||||
opacity: 0.8;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results grid mixin for consumers
|
||||
::ng-deep .results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
// Global search overlay context
|
||||
:host-context(.global-search-overlay) {
|
||||
.search-page {
|
||||
background: var(--background);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: var(--background);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SearchFormComponent } from '../search-form/search-form.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-layout',
|
||||
standalone: true,
|
||||
imports: [
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatProgressSpinner,
|
||||
SearchFormComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './search-layout.component.html',
|
||||
styleUrl: './search-layout.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SearchLayoutComponent {
|
||||
private readonly searchFormComponent = viewChild(SearchFormComponent);
|
||||
|
||||
/** Page title translation key */
|
||||
readonly title = input<string>('PORTALS.SIDEBAR.SEARCH');
|
||||
|
||||
/** Current search term */
|
||||
readonly searchTerm = input<string>('');
|
||||
|
||||
/** Number of results found */
|
||||
readonly resultsCount = input<number>(0);
|
||||
|
||||
/** Whether search is in progress */
|
||||
readonly isLoading = input<boolean>(false);
|
||||
|
||||
/** Whether to show the close button (for dialog mode) */
|
||||
readonly showCloseButton = input<boolean>(false);
|
||||
|
||||
/** Whether to show results count */
|
||||
readonly showResultsCount = input<boolean>(true);
|
||||
|
||||
/** Minimum characters required for search */
|
||||
readonly minSearchLength = input<number>(3);
|
||||
|
||||
/** Emitted when search term changes */
|
||||
readonly searchTermChange = output<string>();
|
||||
|
||||
/** Emitted when close button is clicked */
|
||||
readonly closeClick = output<void>();
|
||||
|
||||
/** Focus the search input */
|
||||
focusSearchInput(): void {
|
||||
this.searchFormComponent()?.focusSearchInput();
|
||||
}
|
||||
|
||||
onSearchTermChange(term: string): void {
|
||||
this.searchTermChange.emit(term);
|
||||
}
|
||||
|
||||
onCloseClick(): void {
|
||||
this.closeClick.emit();
|
||||
}
|
||||
|
||||
/** Check if we should show the "no results" state */
|
||||
get showNoResults(): boolean {
|
||||
return (
|
||||
this.searchTerm().length >= this.minSearchLength() &&
|
||||
this.resultsCount() === 0 &&
|
||||
!this.isLoading()
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if we should show the initial state */
|
||||
get showInitialState(): boolean {
|
||||
return (
|
||||
this.searchTerm().length < this.minSearchLength() &&
|
||||
this.resultsCount() === 0 &&
|
||||
!this.isLoading()
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if we should show results */
|
||||
get showResults(): boolean {
|
||||
return this.resultsCount() > 0 && !this.isLoading();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Shared content grid and card styles
|
||||
// Usage: @use 'path/to/_content-grid.scss' as grid;
|
||||
// @include grid.content-grid;
|
||||
// @include grid.content-card;
|
||||
|
||||
@mixin content-grid($min-width: 140px, $gap: 20px) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax($min-width, 1fr));
|
||||
gap: $gap;
|
||||
}
|
||||
|
||||
@mixin content-card {
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin thumbnail-wrapper {
|
||||
position: relative;
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.poster-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(128, 128, 128, 0.1);
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin type-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
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);
|
||||
color: #fff;
|
||||
|
||||
&.live {
|
||||
color: #ef5350;
|
||||
}
|
||||
&.movie {
|
||||
color: #42a5f5;
|
||||
}
|
||||
&.series {
|
||||
color: #66bb6a;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin remove-button {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin card-info {
|
||||
padding: 8px 10px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.date-info {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,16 @@ export class RecentlyViewedComponent {
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
readonly allFavorites = rxResource({
|
||||
params: () => ({
|
||||
refreshTimestamp: this.refreshTimestamp(),
|
||||
}),
|
||||
stream: () =>
|
||||
this.playlistService.getPortalRecentlyViewed(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
),
|
||||
params: () => {
|
||||
const portalId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (!portalId) return undefined;
|
||||
return {
|
||||
portalId,
|
||||
refreshTimestamp: this.refreshTimestamp(),
|
||||
};
|
||||
},
|
||||
stream: ({ params }) =>
|
||||
this.playlistService.getPortalRecentlyViewed(params.portalId),
|
||||
});
|
||||
|
||||
readonly categories = computed(() => [
|
||||
|
||||
@@ -47,13 +47,16 @@ export class StalkerFavoritesComponent {
|
||||
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
|
||||
|
||||
readonly allFavorites = rxResource({
|
||||
params: () => ({
|
||||
refreshTimestamp: this.refreshTimestamp(),
|
||||
}),
|
||||
stream: () =>
|
||||
this.playlistService.getPortalFavorites(
|
||||
this.stalkerStore.currentPlaylist()?._id
|
||||
),
|
||||
params: () => {
|
||||
const portalId = this.stalkerStore.currentPlaylist()?._id;
|
||||
if (!portalId) return undefined;
|
||||
return {
|
||||
portalId,
|
||||
refreshTimestamp: this.refreshTimestamp(),
|
||||
};
|
||||
},
|
||||
stream: ({ params }) =>
|
||||
this.playlistService.getPortalFavorites(params.portalId),
|
||||
});
|
||||
|
||||
readonly categories = computed(() => [
|
||||
|
||||
@@ -1,76 +1,65 @@
|
||||
@if (itemDetails) {
|
||||
@switch (selectedFilterType()) {
|
||||
@case ('vod') {
|
||||
@if (itemDetails.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub or Ministra) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
} @else if (itemDetails.is_series === true) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view (backClicked)="onVodBack()" />
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<div class="search-page">
|
||||
@if (!itemDetails) {
|
||||
<div class="header">
|
||||
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
|
||||
</div>
|
||||
<app-search-form
|
||||
[singleSelection]="true"
|
||||
[(searchTerm)]="searchTerm"
|
||||
[filters]="filters"
|
||||
[filterConfig]="filterConfig"
|
||||
(filtersChange)="onFiltersChange($any($event))"
|
||||
/>
|
||||
<div class="results-container">
|
||||
@if (searchResultsResource.isLoading()) {
|
||||
<mat-spinner />
|
||||
} @else {
|
||||
<div class="results-grid">
|
||||
@for (
|
||||
item of searchResultsResource.value();
|
||||
track item.id
|
||||
) {
|
||||
<app-search-result-item
|
||||
[title]="item.o_name || item.name"
|
||||
[posterUrl]="item.screenshot_uri"
|
||||
[type]="item.type"
|
||||
(itemClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (searchResultsResource.value().length === 0) {
|
||||
<div class="no-results">
|
||||
<app-playlist-error-view
|
||||
[title]="
|
||||
'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate
|
||||
"
|
||||
[description]="
|
||||
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS'
|
||||
| translate
|
||||
"
|
||||
[showActionButtons]="false"
|
||||
[viewType]="'NO_SEARCH_RESULTS'"
|
||||
/>
|
||||
</div>
|
||||
@if (showingDetails) {
|
||||
<!-- Inline details view -->
|
||||
<div class="details-view">
|
||||
@switch (selectedFilterType()) {
|
||||
@case ('vod') {
|
||||
@if (itemDetails.series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub or Ministra) -->
|
||||
<app-stalker-series-view
|
||||
[vodWithSeries]="itemDetails"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
} @else if (itemDetails.is_series === true) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view (backClicked)="onVodBack()" />
|
||||
} @else if (vodDetailsItem) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[item]="vodDetailsItem"
|
||||
(playClicked)="onVodPlay($event)"
|
||||
(favoriteToggled)="onVodFavoriteToggled($event)"
|
||||
(backClicked)="onVodBack()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@case ('series') {
|
||||
<app-stalker-series-view (backClicked)="itemDetails = null" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<app-search-layout
|
||||
[searchTerm]="searchTerm()"
|
||||
[resultsCount]="resultsCount"
|
||||
[isLoading]="searchResultsResource.isLoading()"
|
||||
[showResultsCount]="true"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
>
|
||||
<!-- Filters (single selection mode for stalker) -->
|
||||
<ng-container filters>
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[ngModel]="filters()[filter.key]"
|
||||
(ngModelChange)="updateFilter(filter.key, $event)"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container results>
|
||||
<div class="results-grid">
|
||||
@for (item of searchResultsResource.value(); track item.id) {
|
||||
<app-content-card
|
||||
[title]="item.o_name || item.name"
|
||||
[posterUrl]="item.screenshot_uri"
|
||||
[type]="selectedFilterType() === 'vod' ? 'movie' : 'series'"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
</app-search-layout>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../shared/styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.details-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, resource, signal } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
@@ -10,47 +10,36 @@ import {
|
||||
Playlist,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
VodDetailsItem,
|
||||
StalkerVodDetails,
|
||||
VodDetailsItem,
|
||||
createStalkerVodItem,
|
||||
} from 'shared-interfaces';
|
||||
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
|
||||
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
|
||||
import { PlaylistErrorViewComponent } from '../../xtream-tauri/playlist-error-view/playlist-error-view.component';
|
||||
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
||||
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
|
||||
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
|
||||
import { StalkerContentTypes } from '../stalker-content-types';
|
||||
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
interface StalkerFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
translationKey: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-stalker-search',
|
||||
imports: [
|
||||
MatCardModule,
|
||||
MatProgressSpinner,
|
||||
PlaylistErrorViewComponent,
|
||||
SearchFormComponent,
|
||||
TranslatePipe,
|
||||
SearchResultItemComponent,
|
||||
ContentCardComponent,
|
||||
FormsModule,
|
||||
MatCheckboxModule,
|
||||
SearchLayoutComponent,
|
||||
StalkerSeriesViewComponent,
|
||||
TranslatePipe,
|
||||
VodDetailsComponent,
|
||||
],
|
||||
templateUrl: './stalker-search.component.html',
|
||||
styles: `
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
styleUrl: './stalker-search.component.scss',
|
||||
})
|
||||
export class StalkerSearchComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
@@ -59,12 +48,12 @@ export class StalkerSearchComponent {
|
||||
private readonly stalkerSession = inject(StalkerSessionService);
|
||||
private readonly store = inject(Store);
|
||||
|
||||
readonly filters = {
|
||||
readonly filters = signal({
|
||||
series: false,
|
||||
vod: true,
|
||||
};
|
||||
});
|
||||
|
||||
readonly filterConfig = [
|
||||
readonly filterConfig: StalkerFilter[] = [
|
||||
{
|
||||
key: 'vod',
|
||||
label: 'Movies',
|
||||
@@ -102,7 +91,6 @@ export class StalkerSearchComponent {
|
||||
if (!playlist) return [];
|
||||
const { portalUrl, macAddress } = playlist;
|
||||
|
||||
// Get token if it's a full stalker portal
|
||||
let token: string | undefined;
|
||||
let serialNumber: string | undefined;
|
||||
if ((playlist as Playlist).isFullStalkerPortal) {
|
||||
@@ -134,7 +122,6 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
);
|
||||
if (response) {
|
||||
// Process items to convert relative URLs to absolute
|
||||
const items = response.js?.data || [];
|
||||
return items.map((item: any) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
@@ -147,32 +134,51 @@ export class StalkerSearchComponent {
|
||||
},
|
||||
});
|
||||
|
||||
/** Check if showing item details */
|
||||
get showingDetails(): boolean {
|
||||
return this.itemDetails !== null;
|
||||
}
|
||||
|
||||
/** Get results count for layout */
|
||||
get resultsCount(): number {
|
||||
return this.searchResultsResource.value()?.length ?? 0;
|
||||
}
|
||||
|
||||
createLinkToPlayVodItv(cmd?: string, title?: string, thumbnail?: string) {
|
||||
this.stalkerStore.createLinkToPlayVod(cmd, title, thumbnail);
|
||||
}
|
||||
|
||||
selectItem(item: any) {
|
||||
// Check if item has embedded series data (vclub mode or Ministra with episodes)
|
||||
const hasEmbeddedSeries = item.series?.length > 0;
|
||||
updateSearchTerm(term: string) {
|
||||
this.searchTerm.set(term);
|
||||
}
|
||||
|
||||
// Detect if this VOD item is a series that needs API fetch (Ministra is_series flag WITHOUT embedded series)
|
||||
// Only set is_series when we need to fetch seasons from API
|
||||
updateFilter(key: string, value: boolean) {
|
||||
if (value) {
|
||||
// Single selection mode - set clicked filter, disable others
|
||||
this.selectedFilterType.set(key);
|
||||
this.filters.update((f) => {
|
||||
const newFilters: Record<string, boolean> = {};
|
||||
Object.keys(f).forEach((k) => {
|
||||
newFilters[k] = k === key;
|
||||
});
|
||||
return newFilters as typeof f;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
selectItem(item: any) {
|
||||
const hasEmbeddedSeries = item.series?.length > 0;
|
||||
const needsSeriesFetch =
|
||||
this.selectedFilterType() === 'vod' &&
|
||||
!hasEmbeddedSeries &&
|
||||
(item.is_series === '1' || item.is_series === 1);
|
||||
|
||||
// Structure item exactly like category-content-view does
|
||||
this.itemDetails = {
|
||||
id: item.id,
|
||||
cmd: item.cmd,
|
||||
// For VOD items with embedded series array (Stalker vclub or Ministra with episode numbers)
|
||||
series: item.series,
|
||||
// Preserve has_files for cmd transformation during playback
|
||||
has_files: item.has_files,
|
||||
// Flag for VOD items that need to fetch seasons from API (Ministra plugin without embedded series)
|
||||
is_series: needsSeriesFetch ? true : undefined,
|
||||
// Store video_id for season fetching if available
|
||||
video_id: item.video_id,
|
||||
info: {
|
||||
movie_image: item.screenshot_uri,
|
||||
@@ -188,17 +194,11 @@ export class StalkerSearchComponent {
|
||||
},
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
console.log('[StalkerSearch] selectItem - hasEmbeddedSeries:', hasEmbeddedSeries);
|
||||
console.log('[StalkerSearch] selectItem - needsSeriesFetch:', needsSeriesFetch);
|
||||
console.log('[StalkerSearch] selectItem - itemDetails:', this.itemDetails);
|
||||
|
||||
this.stalkerStore.setSelectedItem(this.itemDetails);
|
||||
|
||||
switch (this.selectedFilterType()) {
|
||||
case 'vod':
|
||||
this.stalkerStore.setSelectedContentType('vod');
|
||||
// Only create VodDetailsItem for regular VODs (not series of any kind)
|
||||
if (!hasEmbeddedSeries && !needsSeriesFetch) {
|
||||
const playlist = this.currentPlaylist();
|
||||
this.vodDetailsItem = createStalkerVodItem(
|
||||
@@ -217,7 +217,6 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle play from vod-details component */
|
||||
onVodPlay(item: VodDetailsItem): void {
|
||||
if (item.type === 'stalker') {
|
||||
this.createLinkToPlayVodItv(
|
||||
@@ -228,8 +227,10 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle favorite toggle from vod-details component */
|
||||
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
|
||||
onVodFavoriteToggled(event: {
|
||||
item: VodDetailsItem;
|
||||
isFavorite: boolean;
|
||||
}): void {
|
||||
if (event.item.type === 'stalker') {
|
||||
if (event.isFavorite) {
|
||||
this.addToFavorites({
|
||||
@@ -245,37 +246,22 @@ export class StalkerSearchComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle back from vod-details component */
|
||||
onVodBack(): void {
|
||||
this.itemDetails = null;
|
||||
this.vodDetailsItem = null;
|
||||
}
|
||||
|
||||
onFiltersChange(event: { vod: boolean; live: boolean; series: boolean }) {
|
||||
const selectedFilter = Object.keys(event).find((key) => event[key]);
|
||||
if (selectedFilter) {
|
||||
this.selectedFilterType.set(selectedFilter);
|
||||
}
|
||||
}
|
||||
|
||||
addToFavorites(item: any) {
|
||||
console.debug('Add to favorites', item);
|
||||
this.stalkerStore.addToFavorites(item);
|
||||
}
|
||||
|
||||
removeFromFavorites(favoriteId: string) {
|
||||
console.debug('Remove from favorites', favoriteId);
|
||||
this.stalkerStore.removeFromFavorites(favoriteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert relative URLs to absolute URLs using the portal base URL.
|
||||
* Ministra portals often return relative paths for screenshot_uri.
|
||||
*/
|
||||
private processItemUrls(item: any, portalUrl: string): any {
|
||||
const processed = { ...item };
|
||||
|
||||
// Convert screenshot_uri to absolute URL
|
||||
if (processed.screenshot_uri) {
|
||||
processed.screenshot_uri = this.makeAbsoluteUrl(
|
||||
portalUrl,
|
||||
@@ -286,22 +272,16 @@ export class StalkerSearchComponent {
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert relative URL to absolute using portal base URL
|
||||
*/
|
||||
private makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
|
||||
if (!relativePath) return '';
|
||||
// Already absolute URL
|
||||
if (
|
||||
relativePath.startsWith('http://') ||
|
||||
relativePath.startsWith('https://')
|
||||
) {
|
||||
return relativePath;
|
||||
}
|
||||
// Parse the base URL to get origin
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
// Ensure the relative path starts with /
|
||||
const path = relativePath.startsWith('/')
|
||||
? relativePath
|
||||
: `/${relativePath}`;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<div class="downloads">
|
||||
<header class="downloads__header">
|
||||
<div class="downloads__header-left">
|
||||
<button mat-icon-button (click)="goBack()" class="back-btn">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
|
||||
@if (activeCount() > 0) {
|
||||
<span class="downloads__badge">{{ activeCount() }}</span>
|
||||
@@ -25,7 +22,9 @@
|
||||
|
||||
<div class="downloads__folder">
|
||||
<mat-icon>folder</mat-icon>
|
||||
<span class="downloads__folder-path">{{ downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate) }}</span>
|
||||
<span class="downloads__folder-path">{{
|
||||
downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate)
|
||||
}}</span>
|
||||
<button mat-stroked-button (click)="changeFolder()">
|
||||
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
|
||||
</button>
|
||||
@@ -81,7 +80,11 @@
|
||||
<mat-icon>{{
|
||||
getStatusIcon(item.status)
|
||||
}}</mat-icon>
|
||||
{{ 'DOWNLOADS.STATUS.' + item.status.toUpperCase() | translate }}
|
||||
{{
|
||||
'DOWNLOADS.STATUS.' +
|
||||
item.status.toUpperCase()
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
|
||||
@if (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
DownloadItem,
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
})
|
||||
export class DownloadsComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
readonly downloadsService = inject(DownloadsService);
|
||||
|
||||
readonly downloads = this.downloadsService.downloads;
|
||||
@@ -135,10 +134,6 @@ export class DownloadsComponent implements OnInit {
|
||||
await this.downloadsService.clearCompleted(playlistId);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.router.navigate(['..'], { relativeTo: this.route });
|
||||
}
|
||||
|
||||
formatEpisodeLabel(item: DownloadItem): string {
|
||||
if (item.contentType !== 'episode') return '';
|
||||
const s = item.seasonNumber?.toString().padStart(2, '0') || '00';
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
OnInit,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
@@ -23,7 +27,9 @@ import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from '../category-management-dialog/category-management-dialog.component';
|
||||
import { CategoryViewComponent } from '../category-view/category-view.component';
|
||||
import {
|
||||
CategoryViewComponent,
|
||||
} from '../category-view/category-view.component';
|
||||
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
|
||||
import { FavoritesService } from '../services/favorites.service';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
@@ -80,6 +86,12 @@ export class LiveStreamLayoutComponent implements OnInit {
|
||||
};
|
||||
});
|
||||
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly categoryView = viewChild(CategoryViewComponent, {
|
||||
read: ElementRef,
|
||||
});
|
||||
private categoryScrollTop = 0;
|
||||
|
||||
readonly player = this.settingsStore.player;
|
||||
streamUrl: string;
|
||||
favorites = new Map<number, boolean>();
|
||||
@@ -116,12 +128,25 @@ export class LiveStreamLayoutComponent implements OnInit {
|
||||
}
|
||||
|
||||
selectCategory(category: XtreamCategory) {
|
||||
const el = this.categoryView()?.nativeElement;
|
||||
if (el) {
|
||||
this.categoryScrollTop = el.scrollTop;
|
||||
}
|
||||
const categoryId = (category as any).category_id ?? category.id;
|
||||
this.xtreamStore.setSelectedCategory(categoryId);
|
||||
}
|
||||
|
||||
backToCategories() {
|
||||
this.xtreamStore.setSelectedCategory(null);
|
||||
afterNextRender(
|
||||
() => {
|
||||
const el = this.categoryView()?.nativeElement;
|
||||
if (el) {
|
||||
el.scrollTop = this.categoryScrollTop;
|
||||
}
|
||||
},
|
||||
{ injector: this.injector }
|
||||
);
|
||||
}
|
||||
|
||||
openCategoryManagement(): void {
|
||||
|
||||
@@ -37,14 +37,16 @@ export class NavigationComponent {
|
||||
readonly selectedContentType = input<string>();
|
||||
|
||||
readonly categoryClick = output<string>();
|
||||
readonly pageClicked = output<'search' | 'recent' | 'favorites' | 'recently-added'>();
|
||||
readonly pageClicked = output<
|
||||
'search' | 'recent' | 'favorites' | 'recently-added'
|
||||
>();
|
||||
|
||||
readonly currentPlaylist = this.store.selectSignal(
|
||||
selectPlaylistById(this.activatedRoute.snapshot.params.id)
|
||||
);
|
||||
|
||||
readonly isStalkerPlaylist = computed(
|
||||
() => (this.currentPlaylist() as Playlist).macAddress
|
||||
() => (this.currentPlaylist() as Playlist)?.macAddress
|
||||
);
|
||||
|
||||
readonly navigationItems = input<NavigationItem[]>();
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
<h3>{{ 'PORTALS.SIDEBAR.MOVIES' | translate }}</h3>
|
||||
<div class="carousel">
|
||||
@for (item of recentlyAddedVod(); track item.xtream_id) {
|
||||
<app-search-result-item
|
||||
<app-content-card
|
||||
class="carousel-item"
|
||||
[title]="item.title || item.name"
|
||||
[description]="getDate(item) | date : 'mediumDate'"
|
||||
[posterUrl]="item.poster_url || item.stream_icon"
|
||||
[type]="'movie'"
|
||||
(itemClick)="openItem(item, 'vod')"
|
||||
[date]="getDate(item)"
|
||||
(cardClick)="openItem(item, 'vod')"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -24,13 +24,13 @@
|
||||
<h3>{{ 'PORTALS.SIDEBAR.SERIES' | translate }}</h3>
|
||||
<div class="carousel">
|
||||
@for (item of recentlyAddedSeries(); track item.xtream_id) {
|
||||
<app-search-result-item
|
||||
<app-content-card
|
||||
class="carousel-item"
|
||||
[title]="item.title || item.name"
|
||||
[description]="getDate(item) | date : 'mediumDate'"
|
||||
[posterUrl]="item.poster_url || item.cover"
|
||||
[type]="'series'"
|
||||
(itemClick)="openItem(item, 'series')"
|
||||
[date]="getDate(item)"
|
||||
(cardClick)="openItem(item, 'series')"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -42,13 +42,13 @@
|
||||
<h3>{{ 'PORTALS.SIDEBAR.LIVE_TV' | translate }}</h3>
|
||||
<div class="carousel">
|
||||
@for (item of recentlyAddedLive(); track item.xtream_id) {
|
||||
<app-search-result-item
|
||||
<app-content-card
|
||||
class="carousel-item"
|
||||
[title]="item.title || item.name"
|
||||
[description]="getDate(item) | date : 'mediumDate'"
|
||||
[posterUrl]="item.poster_url || item.stream_icon"
|
||||
[type]="'live'"
|
||||
(itemClick)="openItem(item, 'live')"
|
||||
[date]="getDate(item)"
|
||||
(cardClick)="openItem(item, 'live')"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
|
||||
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
import { ContentType } from '../xtream-state';
|
||||
|
||||
@@ -10,7 +9,7 @@ import { ContentType } from '../xtream-state';
|
||||
selector: 'app-recently-added',
|
||||
templateUrl: './recently-added.component.html',
|
||||
styleUrls: ['./recently-added.component.scss'],
|
||||
imports: [DatePipe, SearchResultItemComponent, TranslatePipe],
|
||||
imports: [ContentCardComponent, TranslatePipe],
|
||||
})
|
||||
export class RecentlyAddedComponent {
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
@@ -32,36 +32,16 @@
|
||||
</h3>
|
||||
<div class="items-grid">
|
||||
@for (item of $any(group).value; track item.id) {
|
||||
<mat-card class="item-card" (click)="openItem(item)">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="remove-button"
|
||||
(click)="removeItem($event, item.id)"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<img
|
||||
[src]="
|
||||
item.poster_url ||
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
[alt]="item.title"
|
||||
class="poster"
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
[date]="item.viewed_at"
|
||||
[showRemoveButton]="true"
|
||||
[removeTooltip]="'Remove from history'"
|
||||
(cardClick)="openItem(item)"
|
||||
(remove)="onRemoveItem(item.id)"
|
||||
/>
|
||||
<div class="item-info">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p [class]="item.type" class="type-badge">
|
||||
{{ item.type | titlecase }}
|
||||
</p>
|
||||
<p class="viewed-at">
|
||||
{{ item.viewed_at | date : 'd MMMM, y, HH:mm' }}
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,32 +50,16 @@
|
||||
} @else {
|
||||
<div class="items-grid">
|
||||
@for (item of recentItems(); track item.id) {
|
||||
<mat-card class="item-card" (click)="openItem(item)">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="remove-button"
|
||||
(click)="removeItem($event, item.id)"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<img
|
||||
[src]="item.poster_url || './assets/images/default-poster.png'"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
[alt]="item.title"
|
||||
class="poster"
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
[date]="item.viewed_at"
|
||||
[showRemoveButton]="true"
|
||||
[removeTooltip]="'Remove from history'"
|
||||
(cardClick)="openItem(item)"
|
||||
(remove)="onRemoveItem(item.id)"
|
||||
/>
|
||||
<div class="item-info">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p [class]="item.type" class="type-badge">
|
||||
{{ item.type | titlecase }}
|
||||
</p>
|
||||
<p class="viewed-at">
|
||||
{{ item.viewed_at | date : 'd MMMM, y, hh:mm' }}
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
} } @else {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../shared/styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -24,61 +26,7 @@
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
padding: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.viewed-at {
|
||||
font-size: 12px;
|
||||
}
|
||||
@include grid.content-grid(140px, 20px);
|
||||
}
|
||||
|
||||
.no-items {
|
||||
@@ -93,26 +41,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
&.live {
|
||||
background: #e53935;
|
||||
color: white;
|
||||
}
|
||||
&.movie {
|
||||
background: #1e88e5;
|
||||
color: white;
|
||||
}
|
||||
&.series {
|
||||
background: #43a047;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-section {
|
||||
.section-title {
|
||||
margin: 0 0 1.5rem;
|
||||
@@ -160,4 +88,4 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
import { DatePipe, KeyValuePipe, TitleCasePipe } from '@angular/common';
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import { Component, computed, inject, Optional } from '@angular/core';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { MatCard } from '@angular/material/card';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-viewed',
|
||||
imports: [
|
||||
DatePipe,
|
||||
ContentCardComponent,
|
||||
KeyValuePipe,
|
||||
MatButton,
|
||||
MatCard,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatIconButton,
|
||||
TitleCasePipe,
|
||||
],
|
||||
providers: [],
|
||||
templateUrl: './recently-viewed.component.html',
|
||||
@@ -90,8 +87,7 @@ export class RecentlyViewedComponent {
|
||||
}
|
||||
}
|
||||
|
||||
removeItem(event: Event, itemId: number) {
|
||||
event.stopPropagation();
|
||||
onRemoveItem(itemId: number) {
|
||||
this.xtreamStore.removeRecentItem({
|
||||
itemId,
|
||||
playlistId: this.currentPlaylist().id,
|
||||
|
||||
@@ -1,134 +1,74 @@
|
||||
<div class="search-page">
|
||||
<div class="header sticky-header">
|
||||
<div class="header-top">
|
||||
<div class="header-left">
|
||||
<h2>{{ 'PORTALS.SIDEBAR.SEARCH' | translate }}</h2>
|
||||
|
||||
<div class="type-filters">
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[(ngModel)]="filters[filter.key]"
|
||||
(ngModelChange)="executeSearch()"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@if (!isGlobalSearch && xtreamStore.searchResults().length > 0) {
|
||||
<div class="results-count">
|
||||
Found {{ xtreamStore.searchResults().length }} results
|
||||
</div>
|
||||
}
|
||||
@if (isGlobalSearch) {
|
||||
<button mat-icon-button (click)="dialogRef.close()">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-input-container">
|
||||
<app-search-form
|
||||
[(searchTerm)]="searchTerm"
|
||||
[filters]="filters"
|
||||
[filterConfig]="[]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-container">
|
||||
@if (xtreamStore.searchResults().length > 0) { @if (isGlobalSearch) {
|
||||
<!-- Global Search Results -->
|
||||
<div class="playlist-section">
|
||||
<h2 class="section-title">
|
||||
Found {{ xtreamStore.searchResults().length }} items across
|
||||
{{ (getGroupedResults() | keyvalue).length }} playlists
|
||||
</h2>
|
||||
@for ( group of getGroupedResults() | keyvalue; track group.key ) {
|
||||
<div class="playlist-group">
|
||||
<h3 class="playlist-title">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
{{ group.key }}
|
||||
<span class="item-count"
|
||||
>({{ $any(group).value.length }} items)</span
|
||||
>
|
||||
</h3>
|
||||
<div class="results-grid">
|
||||
@for ( item of $any(group).value; track item.id ) {
|
||||
<app-search-result-item
|
||||
[title]="item.title"
|
||||
[description]="item.description"
|
||||
[posterUrl]="item.poster_url"
|
||||
[type]="item.type"
|
||||
[playlistName]="item.playlist_name"
|
||||
[showPlaylistInfo]="isGlobalSearch"
|
||||
(itemClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Playlist-level Search Results -->
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
<mat-card (click)="selectItem(item)">
|
||||
<mat-card-content>
|
||||
@if (item.poster_url) {
|
||||
<img
|
||||
[src]="item.poster_url"
|
||||
[alt]="item.title"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
class="poster"
|
||||
/>
|
||||
} @if (!item.poster_url) {
|
||||
<div class="poster-placeholder">
|
||||
<mat-icon>movie</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<h3>{{ item.title }}</h3>
|
||||
<p>{{ item.description }}</p>
|
||||
<div class="type-badge" [class]="item.type">
|
||||
{{ item.type }}
|
||||
</div>
|
||||
@if (item.playlist_id && isGlobalSearch) {
|
||||
<div class="playlist-badge">
|
||||
{{ item.playlist_name }}
|
||||
</div>
|
||||
} @if (isGlobalSearch && item.playlist_name) {
|
||||
<div class="playlist-badge">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
<span>{{ item.playlist_name }}</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
} } @else if (searchTerm() && searchTerm().length >= 3) {
|
||||
<div class="no-items">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
|
||||
<p class="search-term-note">No results found for "{{ searchTerm() }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-items initial-state">
|
||||
<mat-icon>search</mat-icon>
|
||||
<div class="no-items-content">
|
||||
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
|
||||
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<app-search-layout
|
||||
[searchTerm]="searchTerm()"
|
||||
[resultsCount]="xtreamStore.searchResults().length"
|
||||
[isLoading]="xtreamStore.isSearching()"
|
||||
[showCloseButton]="isGlobalSearch"
|
||||
[showResultsCount]="!isGlobalSearch"
|
||||
(searchTermChange)="updateSearchTerm($event)"
|
||||
(closeClick)="onCloseDialog()"
|
||||
>
|
||||
<!-- Filters -->
|
||||
<ng-container filters>
|
||||
@for (filter of filterConfig; track filter.key) {
|
||||
<mat-checkbox
|
||||
[ngModel]="filters()[filter.key]"
|
||||
(ngModelChange)="updateFilter(filter.key, $event)"
|
||||
>
|
||||
{{ filter.translationKey | translate }}
|
||||
</mat-checkbox>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Results -->
|
||||
<ng-container results>
|
||||
@if (isGlobalSearch) {
|
||||
<!-- Global Search Results -->
|
||||
<div class="playlist-section">
|
||||
<h2 class="section-title">
|
||||
Found {{ xtreamStore.searchResults().length }} items across
|
||||
{{ (getGroupedResults() | keyvalue).length }} playlists
|
||||
</h2>
|
||||
@for (
|
||||
group of getGroupedResults() | keyvalue;
|
||||
track group.key
|
||||
) {
|
||||
<div class="playlist-group">
|
||||
<h3 class="playlist-title">
|
||||
<mat-icon>playlist_play</mat-icon>
|
||||
{{ group.key }}
|
||||
<span class="item-count"
|
||||
>({{ $any(group).value.length }} items)</span
|
||||
>
|
||||
</h3>
|
||||
<div class="results-grid">
|
||||
@for (item of $any(group).value; track item.id) {
|
||||
<app-search-result-item
|
||||
[title]="item.title"
|
||||
[description]="item.description"
|
||||
[posterUrl]="item.poster_url"
|
||||
[type]="item.type"
|
||||
[playlistName]="item.playlist_name"
|
||||
[showPlaylistInfo]="isGlobalSearch"
|
||||
(itemClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Playlist-level Search Results -->
|
||||
<div class="results-grid">
|
||||
@for (item of xtreamStore.searchResults(); track item.id) {
|
||||
<app-content-card
|
||||
[posterUrl]="item.poster_url"
|
||||
[title]="item.title"
|
||||
[type]="item.type"
|
||||
[showPlaceholder]="true"
|
||||
(cardClick)="selectItem(item)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
</app-search-layout>
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '../../shared/styles/content-grid' as grid;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -6,231 +8,21 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-page {
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
// Use theme background variable or fallback
|
||||
background: var(--background, var(--mat-sidenav-content-background-color, #fff));
|
||||
padding: 20px 0 10px 0;
|
||||
margin-bottom: 20px;
|
||||
// Subtle shadow or border for separation
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
|
||||
mat-checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.results-count {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, rgba(0,0,0,0.7));
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.results-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(200px, 1fr)
|
||||
);
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
mat-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.poster,
|
||||
.poster-placeholder {
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.poster-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.05);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary, rgba(0, 0, 0, 0.6));
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.live {
|
||||
background: rgba(229, 57, 53, 0.1);
|
||||
color: #e53935;
|
||||
}
|
||||
&.movie {
|
||||
background: rgba(30, 136, 229, 0.1);
|
||||
color: #1e88e5;
|
||||
}
|
||||
&.series {
|
||||
background: rgba(67, 160, 71, 0.1);
|
||||
color: #43a047;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-items-content {
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-term-note {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.initial-state {
|
||||
opacity: 0.8;
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
// Results grid styling
|
||||
.results-grid {
|
||||
@include grid.content-grid(140px, 20px);
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
// Global search overlay styles
|
||||
:host-context(.global-search-overlay) {
|
||||
.search-page {
|
||||
background: var(--background);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Ensure sticky header background matches overlay background */
|
||||
.header {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
mat-card {
|
||||
position: relative;
|
||||
|
||||
|
||||
.poster,
|
||||
.poster-placeholder {
|
||||
margin-top: 24px; // Add space for the playlist badge
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.playlist-badge {
|
||||
@@ -263,6 +55,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist grouping styles for global search
|
||||
.playlist-group {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -305,7 +98,7 @@
|
||||
.playlist-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.poster,
|
||||
.poster-placeholder {
|
||||
margin-top: 0;
|
||||
@@ -322,4 +115,4 @@
|
||||
color: var(--text-color-secondary);
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,27 @@ import {
|
||||
inject,
|
||||
Inject,
|
||||
Optional,
|
||||
signal,
|
||||
ViewChild,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { DatabaseService } from 'services';
|
||||
import { XtreamContentItem } from '../data-sources/xtream-data-source.interface';
|
||||
import { ContentType } from '../xtream-state';
|
||||
import { SearchFormComponent } from '../../shared/components/search-form/search-form.component';
|
||||
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
|
||||
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
|
||||
import { SearchResultItemComponent } from '../../shared/components/search-result-item/search-result-item.component';
|
||||
import { XtreamStore } from '../stores/xtream.store';
|
||||
import { SearchFilters } from '../stores/features/with-search.feature';
|
||||
|
||||
interface SearchResultsData {
|
||||
isGlobalSearch: boolean;
|
||||
@@ -38,16 +35,13 @@ interface SearchResultsData {
|
||||
@Component({
|
||||
selector: 'app-search-results',
|
||||
imports: [
|
||||
ContentCardComponent,
|
||||
FormsModule,
|
||||
KeyValuePipe,
|
||||
MatCardModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatInputModule,
|
||||
SearchFormComponent,
|
||||
SearchLayoutComponent,
|
||||
SearchResultItemComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
@@ -56,32 +50,33 @@ interface SearchResultsData {
|
||||
styleUrls: ['./search-results.component.scss'],
|
||||
})
|
||||
export class SearchResultsComponent implements AfterViewInit {
|
||||
@ViewChild(SearchFormComponent) searchFormComponent!: SearchFormComponent;
|
||||
readonly searchLayoutComponent = viewChild(SearchLayoutComponent);
|
||||
readonly xtreamStore = inject(XtreamStore);
|
||||
readonly router = inject(Router);
|
||||
readonly activatedRoute = inject(ActivatedRoute);
|
||||
readonly databaseService = inject(DatabaseService);
|
||||
searchTerm = signal('');
|
||||
filters = {
|
||||
live: true,
|
||||
movie: true,
|
||||
series: true,
|
||||
};
|
||||
|
||||
/** Search term from store */
|
||||
readonly searchTerm = this.xtreamStore.searchTerm;
|
||||
|
||||
/** Search filters from store */
|
||||
readonly filters = this.xtreamStore.searchFilters;
|
||||
|
||||
isGlobalSearch = false;
|
||||
|
||||
readonly filterConfig = [
|
||||
{
|
||||
key: 'live',
|
||||
key: 'live' as keyof SearchFilters,
|
||||
label: 'Live TV',
|
||||
translationKey: 'PORTALS.SIDEBAR.LIVE_TV',
|
||||
},
|
||||
{
|
||||
key: 'movie',
|
||||
key: 'movie' as keyof SearchFilters,
|
||||
label: 'Movies',
|
||||
translationKey: 'PORTALS.SIDEBAR.MOVIES',
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
key: 'series' as keyof SearchFilters,
|
||||
label: 'Series',
|
||||
translationKey: 'PORTALS.SIDEBAR.SERIES',
|
||||
},
|
||||
@@ -94,11 +89,11 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
this.isGlobalSearch = data?.isGlobalSearch || false;
|
||||
|
||||
effect(() => {
|
||||
this.searchTerm();
|
||||
if (this.searchTerm().length >= 3) {
|
||||
const term = this.searchTerm();
|
||||
if (term.length >= 3) {
|
||||
this.executeSearch();
|
||||
} else {
|
||||
this.xtreamStore.resetSearchResults();
|
||||
} else if (term.length === 0) {
|
||||
this.clearResultsOnly();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -106,12 +101,13 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
ngAfterViewInit() {
|
||||
this.xtreamStore.setSelectedContentType(undefined);
|
||||
setTimeout(() => {
|
||||
this.searchFormComponent?.focusSearchInput();
|
||||
this.searchLayoutComponent()?.focusSearchInput();
|
||||
});
|
||||
}
|
||||
|
||||
executeSearch() {
|
||||
const types = Object.entries(this.filters)
|
||||
const filters = this.filters();
|
||||
const types = Object.entries(filters)
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([type]) => type);
|
||||
|
||||
@@ -125,6 +121,30 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update search term in the store
|
||||
*/
|
||||
updateSearchTerm(term: string) {
|
||||
this.xtreamStore.setSearchTerm(term);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single filter in the store
|
||||
*/
|
||||
updateFilter(key: keyof SearchFilters, value: boolean) {
|
||||
this.xtreamStore.updateSearchFilter(key, value);
|
||||
if (this.searchTerm().length >= 3) {
|
||||
this.executeSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear only the results, not the search term/filters
|
||||
*/
|
||||
private clearResultsOnly() {
|
||||
this.xtreamStore.setGlobalSearchResults([]);
|
||||
}
|
||||
|
||||
async searchGlobal(term: string, types: string[]) {
|
||||
try {
|
||||
const results = await this.databaseService.globalSearchContent(
|
||||
@@ -153,7 +173,6 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
]);
|
||||
} else {
|
||||
const type = (item.type === 'movie' ? 'vod' : item.type) as ContentType;
|
||||
this.xtreamStore.resetSearchResults();
|
||||
this.xtreamStore.setSelectedContentType(type);
|
||||
|
||||
this.router.navigate(
|
||||
@@ -164,6 +183,11 @@ export class SearchResultsComponent implements AfterViewInit {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onCloseDialog() {
|
||||
this.dialogRef?.close();
|
||||
}
|
||||
|
||||
getGroupedResults() {
|
||||
const results = this.xtreamStore.searchResults();
|
||||
if (!this.isGlobalSearch) return { default: results };
|
||||
|
||||
@@ -11,19 +11,41 @@ import {
|
||||
XtreamContentItem,
|
||||
} from '../../data-sources/xtream-data-source.interface';
|
||||
|
||||
/**
|
||||
* Search filters configuration
|
||||
*/
|
||||
export interface SearchFilters {
|
||||
live: boolean;
|
||||
movie: boolean;
|
||||
series: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search state for managing search results
|
||||
*/
|
||||
export interface SearchState {
|
||||
searchTerm: string;
|
||||
searchFilters: SearchFilters;
|
||||
searchResults: XtreamContentItem[];
|
||||
globalSearchResults: GlobalSearchResult[];
|
||||
isSearching: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial search filters
|
||||
*/
|
||||
const initialSearchFilters: SearchFilters = {
|
||||
live: true,
|
||||
movie: true,
|
||||
series: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Initial search state
|
||||
*/
|
||||
const initialSearchState: SearchState = {
|
||||
searchTerm: '',
|
||||
searchFilters: initialSearchFilters,
|
||||
searchResults: [],
|
||||
globalSearchResults: [],
|
||||
isSearching: false,
|
||||
@@ -94,6 +116,35 @@ export function withSearch() {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the search term
|
||||
*/
|
||||
setSearchTerm(term: string): void {
|
||||
patchState(store, { searchTerm: term });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set search filters
|
||||
*/
|
||||
setSearchFilters(filters: SearchFilters): void {
|
||||
patchState(store, { searchFilters: filters });
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single filter
|
||||
*/
|
||||
updateSearchFilter(
|
||||
key: keyof SearchFilters,
|
||||
value: boolean
|
||||
): void {
|
||||
patchState(store, {
|
||||
searchFilters: {
|
||||
...store.searchFilters(),
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear search results
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user