mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #710 from 4gray/style/visual-improvements-in-vod-and-serials-view
style/visual-improvements-in-vod-and-serials-view
This commit is contained in:
33 files changed
+2284
-825
No files matched your search
@@ -19,4 +19,3 @@ issue_enrichment:
|
||||
instructions: Feature requests, improvements to existing functionality, performance optimizations, refactoring suggestions, UI/UX enhancements, and any suggestions to make the project better or add new capabilities.
|
||||
- label: documentation
|
||||
instructions: Documentation updates, additions, corrections, or clarifications needed. This includes missing docs, outdated information, unclear instructions, API documentation, code examples, README improvements, and any requests for better explanations or guides.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -20,30 +19,18 @@ import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error
|
||||
@let i = $any(item);
|
||||
<mat-card (click)="itemClicked.emit(item)">
|
||||
@let poster = i.poster_url ?? i.cover;
|
||||
@if (poster) {
|
||||
<img
|
||||
class="stream-icon"
|
||||
[ngSrc]="
|
||||
poster ??
|
||||
<img
|
||||
class="stream-icon"
|
||||
[src]="
|
||||
poster || './assets/images/default-poster.png'
|
||||
"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
fill
|
||||
priority
|
||||
placeholder
|
||||
placeholder="data:image/png;base64,iVBORw0K..."
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
alt="logo"
|
||||
/>
|
||||
} @else {
|
||||
<img
|
||||
class="stream-icon"
|
||||
src="./assets/images/default-poster.png"
|
||||
alt="logo"
|
||||
/>
|
||||
}
|
||||
"
|
||||
loading="lazy"
|
||||
alt="logo"
|
||||
/>
|
||||
@let rating = i.rating ?? i.rating_imdb;
|
||||
@if (rating) {
|
||||
<div
|
||||
@@ -94,7 +81,6 @@ import { PlaylistErrorViewComponent } from '../../../xtream-tauri/playlist-error
|
||||
MatIcon,
|
||||
MatProgressSpinner,
|
||||
MatTooltip,
|
||||
NgOptimizedImage,
|
||||
MatPaginatorModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
(input)="onSearchQueryUpdate(searchInput.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="menu">
|
||||
|
||||
<!-- Desktop menu buttons -->
|
||||
<div class="menu desktop-menu">
|
||||
<button
|
||||
mat-icon-button
|
||||
class="add-playlist-btn"
|
||||
@@ -42,7 +44,6 @@
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
</button>
|
||||
<!-- Combined Filter & Sort button -->
|
||||
<button
|
||||
mat-icon-button
|
||||
#filterMenuTrigger="matMenuTrigger"
|
||||
@@ -71,13 +72,13 @@
|
||||
} @else {
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="menu"
|
||||
[matMenuTriggerFor]="pwaMenu"
|
||||
[attr.aria-label]="'MENU.OPEN' | translate"
|
||||
data-test-id="pwa-menu"
|
||||
>
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
<mat-menu #menu="matMenu">
|
||||
<mat-menu #pwaMenu="matMenu">
|
||||
@if (isHome) {
|
||||
<button
|
||||
mat-menu-item
|
||||
@@ -129,5 +130,94 @@
|
||||
</mat-menu>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Mobile three-dots menu (all buttons consolidated) -->
|
||||
<div class="menu mobile-menu">
|
||||
<button
|
||||
mat-icon-button
|
||||
[matMenuTriggerFor]="mobileActionsMenu"
|
||||
[attr.aria-label]="'MENU.OPEN' | translate"
|
||||
data-test-id="mobile-menu"
|
||||
>
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<app-add-playlist-menu
|
||||
(playlistTypeSelected)="openAddPlaylistDialog($event)"
|
||||
/>
|
||||
<app-filter-sort-menu [menuTrigger]="mobileFilterTrigger" />
|
||||
<mat-menu #mobileActionsMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
[matMenuTriggerFor]="addPlaylistMenuComponent().menu()"
|
||||
data-test-id="mobile-add-playlist"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<span>{{ 'HOME.URL_UPLOAD.ADD_PLAYLIST' | translate }}</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="navigateToGlobalFavorites()"
|
||||
data-test-id="mobile-global-favorites"
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
<span>{{ 'HOME.PLAYLISTS.GLOBAL_FAVORITES' | translate }}</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
#mobileFilterTrigger="matMenuTrigger"
|
||||
[matMenuTriggerFor]="filterSortMenuComponent().menu()"
|
||||
data-test-id="mobile-filter-sort"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="filterSortMenuComponent().activeFiltersCount"
|
||||
[matBadgeHidden]="filterSortMenuComponent().activeFiltersCount === 0"
|
||||
matBadgeSize="small"
|
||||
matBadgeColor="accent"
|
||||
>tune</mat-icon>
|
||||
<span>{{ 'HOME.FILTER_AND_SORT' | translate }}</span>
|
||||
</button>
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="openSettings()"
|
||||
data-test-id="mobile-settings"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
<span>{{ 'MENU.SETTINGS' | translate }}</span>
|
||||
</button>
|
||||
@if (!isDesktop) {
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="openUrl('https://github.com/4gray/iptvnator')"
|
||||
>
|
||||
<mat-icon>recommend</mat-icon>
|
||||
<span>GitHub</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="openUrl('https://github.com/4gray/iptvnator/issues')"
|
||||
>
|
||||
<mat-icon>bug_report</mat-icon>
|
||||
<span>{{ 'MENU.BUG_REPORT' | translate }}</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="openUrl('https://github.com/sponsors/4gray')"
|
||||
>
|
||||
<mat-icon>savings</mat-icon>
|
||||
<span>{{ 'MENU.SUPPORT_PROJECT' | translate }}</span>
|
||||
</button>
|
||||
<mat-divider />
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="openAboutDialog()"
|
||||
>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'MENU.ABOUT' | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -444,6 +444,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile menu visibility
|
||||
.mobile-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 899px) {
|
||||
.search-container {
|
||||
@@ -452,42 +461,96 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.modern-header {
|
||||
height: 64px;
|
||||
padding-left: 70px; // Reduced padding for smaller screens
|
||||
@media (max-width: 768px) {
|
||||
// Hide desktop menu, show mobile menu
|
||||
.desktop-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// Hide brand text, keep only icon
|
||||
.brand-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
padding: 6px 12px 6px 6px;
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
border-radius: 50%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
// Smaller search bar
|
||||
.search-container {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
margin: 0 12px;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 16px;
|
||||
|
||||
.search-input {
|
||||
font-size: 13px;
|
||||
|
||||
&::placeholder {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.modern-header {
|
||||
height: 56px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.logo-pulse {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-text .title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin: 0 12px;
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
margin: 0 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
|
||||
.search-input {
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
gap: 2px;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
@@ -88,31 +88,32 @@
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
@if (stalkerStore.selectedItem()) {
|
||||
<button mat-icon-button (click)="goBackToList()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
} @else if (!isItvCategorySelected) {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (!stalkerStore.selectedItem()) {
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
@if (!isItvCategorySelected) {
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (!isItvCategorySelected) {
|
||||
{{ selectedCategoryTitle() }}
|
||||
}
|
||||
@if (!isItvCategorySelected) {
|
||||
{{ selectedCategoryTitle() }}
|
||||
}
|
||||
|
||||
@if (!contentItems() && !isContentLoading()) {
|
||||
<h2>
|
||||
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
|
||||
</h2>
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
@if (!contentItems() && !isContentLoading()) {
|
||||
<h2>
|
||||
{{ 'PORTALS.SELECT_CATEGORY' | translate }}
|
||||
</h2>
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="scrollable-content">
|
||||
<div
|
||||
class="scrollable-content"
|
||||
[class.no-header]="stalkerStore.selectedItem()"
|
||||
>
|
||||
<router-outlet />
|
||||
</div>
|
||||
<!-- <app-mpv-player-bar /> -->
|
||||
|
||||
@@ -1,159 +1,154 @@
|
||||
@let seasons = seasonsData();
|
||||
<div class="container">
|
||||
@if (displayItem(); as serial) {
|
||||
<div class="image">
|
||||
@if (serial.info.movie_image) {
|
||||
<img
|
||||
[src]="serial.info.movie_image"
|
||||
style="position: relative"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
alt="Series Cover"
|
||||
/>
|
||||
} @else {
|
||||
<div class="placeholder-cover"></div>
|
||||
}
|
||||
</div>
|
||||
@let seasons = regularSeasons();
|
||||
|
||||
<div class="details">
|
||||
<h2>{{ serial.info.name }}</h2>
|
||||
<div>
|
||||
{{ serial.info.description }}
|
||||
</div>
|
||||
@if (serial.info.director) {
|
||||
<div>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.DIRECTOR' | translate }}:
|
||||
</div>
|
||||
{{ serial.info.director }}
|
||||
</div>
|
||||
}
|
||||
@if (serial.info.actors) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
|
||||
{{ serial.info.actors }}
|
||||
</div>
|
||||
}
|
||||
@if (displayItem(); as serial) {
|
||||
<app-content-hero
|
||||
[title]="serial.info.name"
|
||||
[description]="serial.info.description"
|
||||
[posterUrl]="serial.info.movie_image"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<!-- Tags: Year, Genre, Rating -->
|
||||
<ng-container hero-tags>
|
||||
@if (serial.info.releasedate || serial.info.year) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.YEAR' | translate }}:</div>
|
||||
{{ serial.info.releasedate || serial.info.year }}
|
||||
</div>
|
||||
<span class="details__tag">{{
|
||||
serial.info.releasedate || serial.info.year
|
||||
}}</span>
|
||||
}
|
||||
@if (serial.info.genre || serial.info.genres_str) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
|
||||
{{ serial.info.genre || serial.info.genres_str }}
|
||||
</div>
|
||||
<span class="details__tag">{{
|
||||
serial.info.genre || serial.info.genres_str
|
||||
}}</span>
|
||||
}
|
||||
@if (serial.info.rating_imdb) {
|
||||
<div>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.IMDB_RATING' | translate }}:
|
||||
</div>
|
||||
{{ serial.info.rating_imdb }}
|
||||
</div>
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ serial.info.rating_imdb }}
|
||||
</span>
|
||||
}
|
||||
@if (serial.info.rating_kinopoisk) {
|
||||
<div>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
|
||||
</div>
|
||||
{{ serial.info.rating_kinopoisk }}
|
||||
<span class="details__tag">
|
||||
KP {{ serial.info.rating_kinopoisk }}
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
@if (serial.info.actors) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.ACTORS' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ serial.info.actors }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="action-buttons">
|
||||
<app-favorites-button
|
||||
[itemId]="serial.id"
|
||||
(addToFavoritesClicked)="addToFavorites(serial)"
|
||||
(removeFromFavoritesClicked)="
|
||||
removeFromFavorites(serial.id)
|
||||
"
|
||||
/>
|
||||
@if (serial.info.director) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ serial.info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<app-favorites-button
|
||||
[itemId]="serial.id"
|
||||
(addToFavoritesClicked)="addToFavorites(serial)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites(serial.id)"
|
||||
/>
|
||||
</ng-container>
|
||||
|
||||
<!-- Loading state for VOD series seasons -->
|
||||
@if (isVodSeries() && isVodSeriesSeasonsLoading()) {
|
||||
<div class="seasons-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Loading state for VOD series seasons -->
|
||||
@if (isVodSeries() && isVodSeriesSeasonsLoading()) {
|
||||
<div class="seasons-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Loading state for regular series seasons -->
|
||||
@if (!isVodSeries() && isSerialSeasonsLoading()) {
|
||||
<div class="seasons-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="seasons">
|
||||
@if (isVodSeries()) {
|
||||
<!-- VOD series mode: expandable seasons with dynamic episode loading -->
|
||||
@for (item of vodSeriesSeasons(); track item.id) {
|
||||
<mat-divider />
|
||||
<div class="season-header" (click)="toggleSeasonExpanded(item)">
|
||||
<h4>{{ item.name }}</h4>
|
||||
<span class="expand-icon">{{
|
||||
item.isExpanded ? '▼' : '►'
|
||||
}}</span>
|
||||
<!-- Loading state for regular series seasons -->
|
||||
@if (!isVodSeries() && isSerialSeasonsLoading()) {
|
||||
<div class="seasons-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item.isExpanded) {
|
||||
@if (item.isLoading) {
|
||||
<div class="episodes-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
<div class="seasons">
|
||||
@if (isVodSeries()) {
|
||||
<!-- VOD series mode: expandable seasons with dynamic episode loading -->
|
||||
@for (item of vodSeriesSeasons(); track item.id) {
|
||||
<mat-divider />
|
||||
<div
|
||||
class="season-header"
|
||||
(click)="toggleSeasonExpanded(item)"
|
||||
>
|
||||
<h4>{{ item.name }}</h4>
|
||||
<span class="expand-icon">{{
|
||||
item.isExpanded ? '▼' : '►'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@if (item.isExpanded) {
|
||||
@if (item.isLoading) {
|
||||
<div class="episodes-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="episodes">
|
||||
@for (
|
||||
episode of item.episodes;
|
||||
track episode.id ?? $index
|
||||
) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="
|
||||
playVodSeriesEpisode(episode);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
>
|
||||
{{
|
||||
episode.name ||
|
||||
('XTREAM.EPISODE' | translate) +
|
||||
' ' +
|
||||
episode.series_number
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
!item.episodes || item.episodes.length === 0
|
||||
) {
|
||||
<span class="no-episodes">{{
|
||||
'PORTALS.NO_EPISODES' | translate
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<!-- Regular series mode: pre-loaded episodes -->
|
||||
@for (item of seasons; track item.id ?? $index) {
|
||||
<mat-divider />
|
||||
<div class="season-header">
|
||||
<h4>{{ item.name }}</h4>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="episodes">
|
||||
@for (
|
||||
episode of item.episodes;
|
||||
track episode.id || $index
|
||||
) {
|
||||
@for (episode of item.series; track $index) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="
|
||||
playVodSeriesEpisode(episode);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
(click)="playEpisodeClicked(episode, item.cmd)"
|
||||
>
|
||||
{{
|
||||
episode.name ||
|
||||
('XTREAM.EPISODE' | translate) +
|
||||
' ' +
|
||||
episode.series_number
|
||||
}}
|
||||
{{ 'XTREAM.EPISODE' | translate }}
|
||||
{{ episode }}
|
||||
</button>
|
||||
}
|
||||
@if (!item.episodes || item.episodes.length === 0) {
|
||||
<span class="no-episodes">{{
|
||||
'PORTALS.NO_EPISODES' | translate
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<!-- Regular series mode: pre-loaded episodes -->
|
||||
@for (item of seasons; track item.id || $index) {
|
||||
<mat-divider />
|
||||
<h4>{{ item.name }}</h4>
|
||||
<div class="episodes">
|
||||
@for (episode of $any(item).series; track $index) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="playEpisodeClicked(episode, $any(item).cmd)"
|
||||
>
|
||||
{{ 'XTREAM.EPISODE' | translate }} {{ episode }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</app-content-hero>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MatDivider } from '@angular/material/divider';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
/**
|
||||
@@ -26,6 +27,16 @@ interface VodSeriesSeason {
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common interface for regular series seasons
|
||||
*/
|
||||
interface StalkerSeriesSeason {
|
||||
id: string;
|
||||
name: string;
|
||||
cmd?: string;
|
||||
series?: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying series/episodes for Stalker portal content.
|
||||
* Supports three modes:
|
||||
@@ -36,9 +47,10 @@ interface VodSeriesSeason {
|
||||
@Component({
|
||||
selector: 'app-stalker-series-view',
|
||||
templateUrl: './stalker-series-view.component.html',
|
||||
styleUrls: ['../../xtream/detail-view.scss'],
|
||||
styleUrls: ['../../xtream-tauri/detail-view.scss'],
|
||||
imports: [
|
||||
FavoritesButtonComponent,
|
||||
ContentHeroComponent,
|
||||
MatButton,
|
||||
MatDivider,
|
||||
MatProgressSpinner,
|
||||
@@ -112,9 +124,8 @@ export class StalkerSeriesViewComponent {
|
||||
/**
|
||||
* For VOD with embedded series, we create a single "season" with the episodes
|
||||
* For regular series, we use the API-fetched seasons
|
||||
* For VOD series (is_series=1), we use the fetched seasons with dynamic episode loading
|
||||
*/
|
||||
readonly seasonsData = computed(() => {
|
||||
readonly regularSeasons = computed<StalkerSeriesSeason[]>(() => {
|
||||
const vodItem = this.vodWithSeries();
|
||||
if (vodItem?.series?.length > 0) {
|
||||
// VOD with embedded series - create a pseudo-season structure
|
||||
@@ -128,15 +139,8 @@ export class StalkerSeriesViewComponent {
|
||||
];
|
||||
}
|
||||
|
||||
// Check if this is a VOD series (is_series=1)
|
||||
const displayItem = this.displayItem();
|
||||
if (displayItem?.is_series === true) {
|
||||
// Return VOD series seasons (episodes loaded dynamically)
|
||||
return this.vodSeriesSeasons();
|
||||
}
|
||||
|
||||
// Regular series - use API-fetched seasons
|
||||
return this.stalkerStore.getSerialSeasonsResource();
|
||||
return this.stalkerStore.getSerialSeasonsResource() as unknown as StalkerSeriesSeason[];
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -213,7 +217,7 @@ export class StalkerSeriesViewComponent {
|
||||
/**
|
||||
* Play episode - handles both regular series and VOD series
|
||||
*/
|
||||
playEpisodeClicked(episode: any, cmd: string) {
|
||||
playEpisodeClicked(episode: any, cmd?: string) {
|
||||
const item = this.displayItem();
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
cmd,
|
||||
@@ -255,4 +259,9 @@ export class StalkerSeriesViewComponent {
|
||||
removeFromFavorites(serialId: string) {
|
||||
this.stalkerStore.removeFromFavorites(serialId);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
// Clear selectedItem to return to category view
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
}
|
||||
}
|
||||
@@ -724,8 +724,6 @@ export const StalkerStore = signalStore(
|
||||
...(series ? { series: String(series) } : {}),
|
||||
};
|
||||
|
||||
console.log('[StalkerStore] series is :', series);
|
||||
|
||||
// Use makeAuthenticatedRequest for automatic retry on auth failure
|
||||
const playlist = store.currentPlaylist() as Playlist;
|
||||
let response: any;
|
||||
|
||||
@@ -46,6 +46,11 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
|
||||
ngOnInit() {
|
||||
const { categoryId } = this.activatedRoute.snapshot.params;
|
||||
|
||||
// Clear any previous selectedItem when entering category view
|
||||
// This ensures the content-header is visible
|
||||
this.store.setSelectedItem(null);
|
||||
|
||||
// Only set category if it's different from the currently selected one
|
||||
// This preserves the page state when navigating back from detail view
|
||||
if (
|
||||
|
||||
@@ -1,140 +1,360 @@
|
||||
// ============================================================================
|
||||
// Netflix-style Detail View - Cinematic Streaming Design
|
||||
// ============================================================================
|
||||
|
||||
:host {
|
||||
--poster-width: 220px;
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
// Light theme colors (default - no .dark-theme class on body)
|
||||
--hero-gradient-start: rgba(255, 250, 245, 0);
|
||||
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
|
||||
--hero-gradient-end: rgba(255, 250, 245, 1);
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--accent-color: #b81d24;
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
// Dark theme overrides - cinematic dark mode
|
||||
:host-context(.dark-theme) {
|
||||
--hero-gradient-start: rgba(20, 20, 20, 0);
|
||||
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
|
||||
--hero-gradient-end: rgba(20, 20, 20, 1);
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--accent-color: #e50914;
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Details Section - Content Elements
|
||||
// ============================================================================
|
||||
|
||||
.details {
|
||||
h2 {
|
||||
word-wrap: break-word;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
// Styles for projected content elements
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-gap: 20px;
|
||||
grid-template-columns: minmax(250px, 30%) minmax(300px, 1fr);
|
||||
grid-template-areas:
|
||||
'image details'
|
||||
'seasons seasons';
|
||||
padding: 20px;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
&__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: var(--tag-bg);
|
||||
border: 1px solid var(--tag-border);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-size: cover;
|
||||
z-index: -1;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.seasons {
|
||||
padding: 20px;
|
||||
|
||||
.episodes {
|
||||
margin-bottom: 10px;
|
||||
|
||||
button {
|
||||
margin-top: 5px;
|
||||
&--rating {
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
.label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin-bottom: 2px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Buttons - Play, Favorite
|
||||
// ============================================================================
|
||||
|
||||
.play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
color: #141414;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.favorite-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: rgba(109, 109, 110, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: rgba(229, 9, 20, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 9, 20, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Sections - Below the hero fold
|
||||
// ============================================================================
|
||||
|
||||
.content-section {
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&__title {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seasons & Episodes - Inside details section
|
||||
// ============================================================================
|
||||
|
||||
.seasons {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
.season-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.episodes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 8px 0 16px;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.episodes-loading,
|
||||
.seasons-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.no-episodes {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Seasons loading when inside details
|
||||
.details .seasons-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
// Season container when inside details section
|
||||
.details app-season-container {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YouTube Trailer Section - Inside details
|
||||
// ============================================================================
|
||||
|
||||
.youtube-trailer {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trailer-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Legacy container support (for gradual migration)
|
||||
// ============================================================================
|
||||
|
||||
.container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.image {
|
||||
grid-area: image;
|
||||
margin: 0;
|
||||
border-radius: 12px;
|
||||
max-width: 100%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
.placeholder-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 2/3;
|
||||
background-color: #999;
|
||||
border-radius: 12px;
|
||||
|
||||
&::after {
|
||||
content: 'No cover';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
display: none; // Hidden - now using .poster in hero
|
||||
}
|
||||
|
||||
app-season-container {
|
||||
grid-area: seasons;
|
||||
width: 100%;
|
||||
}
|
||||
// ============================================================================
|
||||
// Mobile Responsive - Option A: Keep poster grid layout
|
||||
// ============================================================================
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
'image'
|
||||
'details'
|
||||
'seasons';
|
||||
padding: 10px;
|
||||
:host {
|
||||
--poster-width: 120px;
|
||||
}
|
||||
|
||||
.image,
|
||||
.details {
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
flex: 1 1 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
max-height: 500px;
|
||||
object-fit: contain;
|
||||
border-radius: 0;
|
||||
}
|
||||
// This class is in ContentHeroComponent now, but these rules apply to children?
|
||||
// No, .play-btn is a child of the PROJECTED content.
|
||||
// So we should target the buttons directly for mobile
|
||||
}
|
||||
|
||||
.details {
|
||||
padding: 0 10px;
|
||||
.play-btn,
|
||||
.favorite-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.youtube-trailer {
|
||||
width: 100% !important;
|
||||
margin-top: 16px;
|
||||
|
||||
.trailer-wrapper {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.seasons {
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 100;
|
||||
font-size: 0.9em;
|
||||
padding: 5px 0;
|
||||
text-decoration: underline;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 10px;
|
||||
}
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
|
||||
.youtube-trailer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
max-width: 650px;
|
||||
margin: 0 auto;
|
||||
animation: fadeInUp 0.5s ease-out 0.4s both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility - keep .label for backwards compatibility
|
||||
// ============================================================================
|
||||
|
||||
.label {
|
||||
font-weight: 400;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,39 +1,97 @@
|
||||
<h3>Seasons and Episodes</h3>
|
||||
@if (selectedSeason) {
|
||||
<button
|
||||
mat-button
|
||||
class="back-to-seasons"
|
||||
(click)="selectedSeason = undefined"
|
||||
>
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to seasons</span>
|
||||
</button>
|
||||
}
|
||||
<div class="seasons-container">
|
||||
@if (!selectedSeason) {
|
||||
@for (season of seasons() | keyvalue: compareSeasons; track $index) {
|
||||
<mat-card class="season-item" (click)="selectSeason(season.key)">
|
||||
<mat-card-content>Season {{ season.key }}</mat-card-content>
|
||||
</mat-card>
|
||||
<section class="seasons-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Seasons and Episodes</h3>
|
||||
@if (selectedSeason) {
|
||||
<button class="back-btn" (click)="selectedSeason = undefined">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to seasons</span>
|
||||
</button>
|
||||
}
|
||||
} @else {
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
<mat-card class="episode-item" (click)="selectEpisode(episode)">
|
||||
@if (episode.info?.movie_image) {
|
||||
<mat-card-content
|
||||
><img
|
||||
[src]="episode.info.movie_image"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
class="episode-cover"
|
||||
/></mat-card-content>
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (!selectedSeason) {
|
||||
<!-- Seasons Grid -->
|
||||
<div class="seasons-grid">
|
||||
@for (
|
||||
season of seasons() | keyvalue: compareSeasons;
|
||||
track $index
|
||||
) {
|
||||
<button
|
||||
class="season-card"
|
||||
(click)="selectSeason(season.key)"
|
||||
[class.season-card--has-episodes]="season.value?.length"
|
||||
>
|
||||
<div class="season-card__number">{{ season.key }}</div>
|
||||
<div class="season-card__label">Season</div>
|
||||
@if (season.value?.length) {
|
||||
<div class="season-card__episodes">
|
||||
{{ season.value.length }}
|
||||
{{
|
||||
season.value.length === 1
|
||||
? 'Episode'
|
||||
: 'Episodes'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
<div class="season-card__arrow">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<div class="episode-title">
|
||||
{{ episode.episode_num }}. {{ episode.title }}
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Episodes Grid -->
|
||||
<div class="episodes-grid">
|
||||
@for (episode of seasons()[selectedSeason]; track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-card"
|
||||
(click)="selectEpisode(episode)"
|
||||
>
|
||||
<div class="episode-card__thumbnail">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
<img
|
||||
[src]="episodeInfo.movie_image"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-episode.png'
|
||||
"
|
||||
alt="Episode thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<div class="episode-card__placeholder">
|
||||
<mat-icon>play_circle_outline</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<div class="episode-card__overlay">
|
||||
<div class="episode-card__play">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__info">
|
||||
<h4 class="episode-card__title">
|
||||
{{ episode.episode_num }}. {{ episode.title }}
|
||||
</h4>
|
||||
@if (episodeInfo?.plot) {
|
||||
<p class="episode-card__plot">
|
||||
{{ episodeInfo.plot }}
|
||||
</p>
|
||||
}
|
||||
@if (episodeInfo?.duration) {
|
||||
<span class="episode-card__duration">
|
||||
{{ episodeInfo.duration }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,41 +1,465 @@
|
||||
.seasons-container {
|
||||
// ============================================================================
|
||||
// Netflix-style Seasons & Episodes Component
|
||||
// ============================================================================
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
// Default dark theme colors (matches detail-view)
|
||||
--card-bg: rgba(50, 50, 50, 0.8);
|
||||
--card-bg-hover: rgba(70, 70, 70, 0.9);
|
||||
--card-border: rgba(255, 255, 255, 0.15);
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.75);
|
||||
--text-muted: rgba(255, 255, 255, 0.55);
|
||||
--accent-color: #e50914;
|
||||
--accent-hover: #f40612;
|
||||
}
|
||||
|
||||
// Light theme overrides - only when body does NOT have .dark-theme
|
||||
:host-context(body:not(.dark-theme)) {
|
||||
--card-bg: rgba(0, 0, 0, 0.06);
|
||||
--card-bg-hover: rgba(0, 0, 0, 0.1);
|
||||
--card-border: rgba(0, 0, 0, 0.12);
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.55);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Section Layout
|
||||
// ============================================================================
|
||||
|
||||
.seasons-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #fff; // Force white for dark mode
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
color: #fff; // Force white for dark mode
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme overrides for header
|
||||
:host-context(body:not(.dark-theme)) {
|
||||
.section-title {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.back-btn {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
color: #1a1a1a;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seasons Grid
|
||||
// ============================================================================
|
||||
|
||||
.seasons-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.season-item,
|
||||
.episode-item {
|
||||
min-height: 100px;
|
||||
.season-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 150px;
|
||||
padding: 16px 12px;
|
||||
min-height: 80px;
|
||||
background: rgba(50, 50, 50, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--accent-color);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--card-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.season-card__arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
&__number {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #fff; // Force white
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6); // Force light
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__episodes {
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(255, 255, 255, 0.7); // Force light
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateX(8px) translateY(-50%);
|
||||
opacity: 0;
|
||||
color: var(--accent-color);
|
||||
transition: all 0.25s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-weight: normal;
|
||||
// Light theme overrides for season card
|
||||
:host-context(body:not(.dark-theme)) {
|
||||
.season-card {
|
||||
&__number {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
&__label {
|
||||
color: rgba(26, 26, 26, 0.6);
|
||||
}
|
||||
&__episodes {
|
||||
color: rgba(26, 26, 26, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.episode-item {
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
// ============================================================================
|
||||
// Episodes Grid
|
||||
// ============================================================================
|
||||
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.episode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
background: var(--card-bg-hover);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
|
||||
.episode-card__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.episode-card__play {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.episode-card__thumbnail img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.episode-cover {
|
||||
&__thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
&__play {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(0.8);
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow: 0 4px 20px rgba(229, 9, 20, 0.4);
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #fff;
|
||||
margin-left: 3px; // Optical centering for play icon
|
||||
}
|
||||
}
|
||||
|
||||
&__number {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #fff; // Always white on dark badge
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding: 10px 12px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--card-bg); // Ensure background for contrast
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
color: #fff; // Force white in dark mode for readability
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
|
||||
// Truncate long titles
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__plot {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.7); // Force light color
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
|
||||
// Truncate plot
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__duration {
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(255, 255, 255, 0.55); // Force light color
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.back-to-seasons {
|
||||
margin-bottom: 5px;
|
||||
// Light theme text overrides
|
||||
:host-context(body:not(.dark-theme)) {
|
||||
.episode-card__title {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.episode-card__plot {
|
||||
color: rgba(26, 26, 26, 0.75);
|
||||
}
|
||||
.episode-card__duration {
|
||||
color: rgba(26, 26, 26, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Responsive
|
||||
// ============================================================================
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.seasons-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.seasons-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.season-card {
|
||||
padding: 16px 12px;
|
||||
min-height: 80px;
|
||||
|
||||
&__number {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.episodes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.episode-card {
|
||||
flex-direction: row;
|
||||
|
||||
&__thumbnail {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__plot {
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
&__number {
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.season-card,
|
||||
.episode-card {
|
||||
animation: slideInUp 0.4s ease backwards;
|
||||
|
||||
@for $i from 1 through 12 {
|
||||
&:nth-child(#{$i}) {
|
||||
animation-delay: #{$i * 0.05}s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import { Component, EventEmitter, Output, input } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { XtreamSerieEpisode } from 'shared-interfaces';
|
||||
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
imports: [KeyValuePipe, MatCardModule, MatIcon, MatButton],
|
||||
imports: [KeyValuePipe, MatIcon],
|
||||
})
|
||||
export class SeasonContainerComponent {
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
@@ -29,4 +27,17 @@ export class SeasonContainerComponent {
|
||||
selectEpisode(episode: XtreamSerieEpisode) {
|
||||
this.episodeClicked.emit(episode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get episode info - returns undefined if info is an empty array
|
||||
* (Xtream API returns [] when no metadata available instead of null/object)
|
||||
*/
|
||||
getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
if (Array.isArray(episode.info) || !episode.info) {
|
||||
return undefined;
|
||||
}
|
||||
return episode.info;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +1,76 @@
|
||||
@let item = selectedItem();
|
||||
@if (item?.info) {
|
||||
<div
|
||||
class="container"
|
||||
[style.background]="
|
||||
item.info?.backdrop_path && item.info.backdrop_path.length > 0
|
||||
? 'linear-gradient(to top, rgba(29,29,29,1) 0%, rgba(0,0,0,0.5) 100%), url(' +
|
||||
item.info.backdrop_path[0] +
|
||||
') repeat-x'
|
||||
: 'none'
|
||||
"
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<app-content-hero
|
||||
[title]="item.info?.name"
|
||||
[description]="item.info?.plot"
|
||||
[posterUrl]="item.info?.cover"
|
||||
[backdropUrl]="item.info?.backdrop_path?.[0]"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<div class="image">
|
||||
@if (item.info?.cover) {
|
||||
<img
|
||||
[src]="item.info.cover"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{ item.info?.name }}</h2>
|
||||
@if (item.info?.plot) {
|
||||
<label>
|
||||
{{ item.info.plot }}
|
||||
</label>
|
||||
}
|
||||
<!-- Tags: Year, Genre, Episode Runtime, Rating -->
|
||||
<ng-container hero-tags>
|
||||
@if (item.info?.releaseDate) {
|
||||
<label>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.RELEASE_DATE' | translate }}:
|
||||
</div>
|
||||
{{ item.info.releaseDate }}
|
||||
</label>
|
||||
<span class="details__tag">{{
|
||||
item.info.releaseDate | slice: 0 : 4
|
||||
}}</span>
|
||||
}
|
||||
@if (item.info?.genre) {
|
||||
<label>
|
||||
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
|
||||
{{ item.info.genre }}
|
||||
</label>
|
||||
<span class="details__tag">{{ item.info.genre }}</span>
|
||||
}
|
||||
@if (item.info?.episode_run_time) {
|
||||
<span class="details__tag"
|
||||
>{{ item.info.episode_run_time }} min/ep</span
|
||||
>
|
||||
}
|
||||
@if (item.info?.rating) {
|
||||
<label>
|
||||
<div class="label">{{ 'XTREAM.RATING' | translate }}:</div>
|
||||
{{ item.info?.rating }}
|
||||
</label>
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ item.info.rating }}
|
||||
</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
@if (item.info?.cast) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.CAST' | translate }}:</div>
|
||||
{{ item.info.cast }}
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
|
||||
<span class="value">{{ item.info.cast }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.director) {
|
||||
<label>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.DIRECTOR' | translate }}:
|
||||
</div>
|
||||
{{ item.info.director }}
|
||||
</label>
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ item.info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.episode_run_time) {
|
||||
<label>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.EPISODE_RUN_TIME' | translate }}:
|
||||
</div>
|
||||
{{ item.info.episode_run_time }}
|
||||
</label>
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
@if (!isFavorite()) {
|
||||
<button class="favorite-btn" (click)="toggleFavorite()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
class="favorite-btn favorite-btn--active"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
<div class="action-buttons">
|
||||
@if (!isFavorite()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>star_outline</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Seasons & Episodes -->
|
||||
<app-season-container
|
||||
[seasons]="item.episodes"
|
||||
(episodeClicked)="playEpisode($event)"
|
||||
/>
|
||||
</div>
|
||||
</app-content-hero>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { Location, SlicePipe } from '@angular/common';
|
||||
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import { XtreamSerieEpisode } from 'shared-interfaces';
|
||||
import { SeasonContainerComponent } from '../season-container/season-container.component';
|
||||
import { XtreamStore } from '../xtream.store';
|
||||
@@ -11,9 +12,16 @@ import { XtreamStore } from '../xtream.store';
|
||||
selector: 'app-serial-details',
|
||||
templateUrl: './serial-details.component.html',
|
||||
styleUrls: ['../detail-view.scss'],
|
||||
imports: [MatButton, MatIcon, SeasonContainerComponent, TranslatePipe],
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
MatIcon,
|
||||
SeasonContainerComponent,
|
||||
SlicePipe,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class SerialDetailsComponent {
|
||||
export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
private readonly location = inject(Location);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
|
||||
@@ -33,6 +41,10 @@ export class SerialDetailsComponent {
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
}
|
||||
|
||||
playEpisode(episode: XtreamSerieEpisode) {
|
||||
this.addToRecentlyViewed(this.route.snapshot.params.serialId);
|
||||
|
||||
@@ -57,4 +69,8 @@ export class SerialDetailsComponent {
|
||||
this.xtreamStore.currentPlaylist().id
|
||||
);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
@@ -1,123 +1,104 @@
|
||||
@let item = selectedItem();
|
||||
@if (item && item.info) {
|
||||
<div
|
||||
class="container"
|
||||
[style.background]="
|
||||
item?.info?.backdrop_path && item?.info?.backdrop_path.length > 0
|
||||
? theme() === 'DARK_THEME'
|
||||
? 'linear-gradient(to top, rgba(29,29,29,1) 0%, rgba(0,0,0,0.5) 100%), url(' +
|
||||
item?.info?.backdrop_path[0] +
|
||||
') repeat-x'
|
||||
: 'linear-gradient(to top, rgba(255,255,255,1) 0%, rgba(255,255,255,0.5) 100%), url(' +
|
||||
item?.info?.backdrop_path[0] +
|
||||
') repeat-x'
|
||||
: 'none'
|
||||
"
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<app-content-hero
|
||||
[title]="item.info?.name || item?.movie_data?.name"
|
||||
[description]="item.info.description || item.info.plot"
|
||||
[posterUrl]="item.info.movie_image"
|
||||
[backdropUrl]="item.info?.backdrop_path?.[0]"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<div class="image">
|
||||
@if (item.info?.movie_image) {
|
||||
<img
|
||||
[src]="item.info?.movie_image"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
alt="Movie poster"
|
||||
/>
|
||||
<!-- Tags: Year, Genre, Duration, Rating -->
|
||||
<ng-container hero-tags>
|
||||
@if (item.info?.releasedate) {
|
||||
<span class="details__tag">{{
|
||||
item.info.releasedate | slice: 0 : 4
|
||||
}}</span>
|
||||
}
|
||||
@if (item.info?.genre) {
|
||||
<span class="details__tag">{{ item.info.genre }}</span>
|
||||
}
|
||||
@if (item.info?.duration) {
|
||||
<span class="details__tag">{{ item.info.duration }}</span>
|
||||
}
|
||||
@if (item.info?.rating_imdb) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ item.info.rating_imdb }}
|
||||
</span>
|
||||
}
|
||||
@if (item.info?.country) {
|
||||
<span class="details__tag">{{ item.info.country }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
@if (item.info?.actors || item.info?.cast) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<span class="value">{{
|
||||
item.info.actors || item.info.cast
|
||||
}}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.director) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.DIRECTOR' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ item.info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.rating_kinopoisk) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.KINOPOISK_RATING' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ item.info.rating_kinopoisk }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<button class="play-btn" (click)="playVod(item)">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
</button>
|
||||
@if (!isFavorite()) {
|
||||
<button class="favorite-btn" (click)="toggleFavorite()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<img
|
||||
src="./assets/images/default-poster.png"
|
||||
alt="Default poster"
|
||||
/>
|
||||
<button
|
||||
class="favorite-btn favorite-btn--active"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{ item?.info?.name || item?.movie_data?.name }}</h2>
|
||||
@if (item?.info?.description || item?.info?.plot) {
|
||||
<div>
|
||||
{{ item?.info?.description || item?.info?.plot }}
|
||||
</ng-container>
|
||||
|
||||
<!-- YouTube Trailer -->
|
||||
@if (item.info?.youtube_trailer) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="
|
||||
'https://www.youtube.com/embed/' +
|
||||
item.info.youtube_trailer | safe
|
||||
"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
@if (item?.info?.releasedate) {
|
||||
<div class="label">
|
||||
{{ 'XTREAM.RELEASE_DATE' | translate }}:
|
||||
</div>
|
||||
{{ item?.info?.releasedate }}
|
||||
}
|
||||
@if (item?.info?.genre) {
|
||||
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
|
||||
{{ item?.info?.genre }}
|
||||
}
|
||||
@if (item?.info?.country) {
|
||||
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
|
||||
{{ item?.info?.country }}
|
||||
}
|
||||
@if (item?.info?.actors || item?.info?.cast) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
|
||||
{{ item?.info?.actors || item?.info?.cast }}
|
||||
</div>
|
||||
}
|
||||
@if (item?.info?.director) {
|
||||
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
|
||||
{{ item?.info?.director }}
|
||||
}
|
||||
@if (item?.info?.duration) {
|
||||
<div class="label">{{ 'XTREAM.DURATION' | translate }}:</div>
|
||||
{{ item?.info?.duration }}
|
||||
}
|
||||
@if (item?.info?.rating_imdb) {
|
||||
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
|
||||
{{ item?.info?.rating_imdb }}
|
||||
}
|
||||
@if (item?.info?.rating_kinopoisk) {
|
||||
<div class="label">
|
||||
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
|
||||
</div>
|
||||
{{ item?.info?.rating_kinopoisk }}
|
||||
}
|
||||
<div class="action-buttons">
|
||||
<button mat-flat-button color="accent" (click)="playVod(item)">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}</button
|
||||
>
|
||||
@if (!isFavorite()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>star_outline</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (item?.info?.youtube_trailer) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="450"
|
||||
[src]="
|
||||
'https://www.youtube.com/embed/' +
|
||||
item.info?.youtube_trailer | safe
|
||||
"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</app-content-hero>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Location, SlicePipe } from '@angular/common';
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import { XtreamVodDetails } from 'shared-interfaces';
|
||||
import { SettingsStore } from '../../services/settings-store.service';
|
||||
import { XtreamStore } from '../xtream.store';
|
||||
@@ -13,14 +14,16 @@ import { SafePipe } from './safe.pipe';
|
||||
templateUrl: './vod-details.component.html',
|
||||
styleUrls: ['../detail-view.scss'],
|
||||
imports: [
|
||||
MatButton,
|
||||
ContentHeroComponent,
|
||||
MatIcon,
|
||||
SafePipe,
|
||||
SlicePipe,
|
||||
TranslateModule,
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
})
|
||||
export class VodDetailsComponent implements OnInit, OnDestroy {
|
||||
private location = inject(Location);
|
||||
private settingsStore = inject(SettingsStore);
|
||||
private route = inject(ActivatedRoute);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
@@ -67,4 +70,8 @@ export class VodDetailsComponent implements OnInit, OnDestroy {
|
||||
playlist: this.xtreamStore.currentPlaylist,
|
||||
});
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
@@ -13,21 +13,20 @@
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
@if (xtreamStore.selectedItem()) {
|
||||
<button mat-icon-button (click)="historyBack()">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
} @else {
|
||||
@if (!xtreamStore.selectedItem()) {
|
||||
<div class="content-header">
|
||||
<h2>
|
||||
<button mat-icon-button disabled>
|
||||
<mat-icon>category</mat-icon>
|
||||
</button>
|
||||
}
|
||||
{{ getContentLabel() }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="scrollable-content">
|
||||
{{ getContentLabel() }}
|
||||
</h2>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="scrollable-content"
|
||||
[class.no-header]="xtreamStore.selectedItem()"
|
||||
>
|
||||
<router-outlet />
|
||||
</div>
|
||||
<!-- <app-mpv-player-bar /> -->
|
||||
|
||||
@@ -49,4 +49,9 @@
|
||||
.scrollable-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&.no-header {
|
||||
// When header is hidden, content starts from top
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
|
||||
categoryClicked(category: XtreamCategory) {
|
||||
const categoryId = (category as any).category_id ?? category.id;
|
||||
|
||||
// Clear any selected item when switching categories
|
||||
this.xtreamStore.setSelectedItem(null);
|
||||
|
||||
this.xtreamStore.setSelectedCategory(Number(categoryId));
|
||||
|
||||
this.router.navigate([categoryId], {
|
||||
@@ -58,7 +62,10 @@ export class XtreamMainContainerComponent implements OnInit {
|
||||
: 'Category Content';
|
||||
|
||||
// Show page number when viewing category content (not detail view)
|
||||
if (!this.xtreamStore.selectedItem() && this.xtreamStore.getTotalPages() > 1) {
|
||||
if (
|
||||
!this.xtreamStore.selectedItem() &&
|
||||
this.xtreamStore.getTotalPages() > 1
|
||||
) {
|
||||
const currentPage = this.xtreamStore.page() + 1; // +1 because page is 0-indexed
|
||||
const totalPages = this.xtreamStore.getTotalPages();
|
||||
const pageLabel = this.translateService.instant('PORTALS.PAGE');
|
||||
|
||||
@@ -6,26 +6,21 @@
|
||||
| sortBy: sortType();
|
||||
track $index
|
||||
) {
|
||||
@let imgSrc = item.stream_icon || item.cover || item.logo;
|
||||
<mat-card (click)="itemClicked.emit(item)">
|
||||
<mat-card-content>
|
||||
@if (item.stream_icon || item.cover || item.logo) {
|
||||
@if (imgSrc) {
|
||||
<img
|
||||
class="stream-icon"
|
||||
[ngSrc]="
|
||||
item.stream_icon || item.cover || item.logo
|
||||
"
|
||||
fill
|
||||
priority
|
||||
placeholder
|
||||
placeholder="data:image/png;base64,iVBORw0K..."
|
||||
[src]="imgSrc"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-poster.png'
|
||||
"
|
||||
loading="lazy"
|
||||
alt="channel logo"
|
||||
/>
|
||||
}
|
||||
@if (!item.stream_icon && !item.cover && !item.logo) {
|
||||
} @else {
|
||||
<div class="stream-icon-placeholder"></div>
|
||||
}
|
||||
@if (item.rating || item.rating_imdb) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -19,7 +18,6 @@ import { PortalStore } from '../portal.store';
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
NgOptimizedImage,
|
||||
PlaylistErrorViewComponent,
|
||||
TranslateModule,
|
||||
],
|
||||
|
||||
@@ -1,33 +1,97 @@
|
||||
<h3>Seasons and episodes</h3>
|
||||
<div class="seasons-container">
|
||||
@if (!selectedSeason) {
|
||||
@for (season of seasons | keyvalue; track season) {
|
||||
<mat-card
|
||||
class="season-item"
|
||||
(click)="selectSeason(season.key)"
|
||||
>
|
||||
<mat-card-content>Season {{ season.key }}</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
} @else {
|
||||
<mat-card class="episode-item" (click)="selectedSeason = undefined">
|
||||
<div class="episode-title">< Back to seasons</div>
|
||||
</mat-card>
|
||||
@for (episode of seasons[selectedSeason]; track episode) {
|
||||
<mat-card
|
||||
class="episode-item"
|
||||
(click)="selectEpisode(episode)"
|
||||
>
|
||||
@if (episode.info.movie_image) {
|
||||
<mat-card-content
|
||||
><img [src]="episode.info.movie_image" class="episode-cover"
|
||||
/></mat-card-content>
|
||||
<section class="seasons-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Seasons and Episodes</h3>
|
||||
@if (selectedSeason) {
|
||||
<button class="back-btn" (click)="selectedSeason = undefined">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
<span>Back to seasons</span>
|
||||
</button>
|
||||
}
|
||||
<div class="episode-title">
|
||||
{{ episode.episode_num }}. {{ episode.title }}
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="content-container">
|
||||
@if (!selectedSeason) {
|
||||
<!-- Seasons Grid -->
|
||||
<div class="seasons-grid">
|
||||
@for (
|
||||
season of seasons | keyvalue: compareSeasons;
|
||||
track $index
|
||||
) {
|
||||
<button
|
||||
class="season-card"
|
||||
(click)="selectSeason(season.key)"
|
||||
[class.season-card--has-episodes]="season.value?.length"
|
||||
>
|
||||
<div class="season-card__number">{{ season.key }}</div>
|
||||
<div class="season-card__label">Season</div>
|
||||
@if (season.value?.length) {
|
||||
<div class="season-card__episodes">
|
||||
{{ season.value.length }}
|
||||
{{
|
||||
season.value.length === 1
|
||||
? 'Episode'
|
||||
: 'Episodes'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
<div class="season-card__arrow">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Episodes Grid -->
|
||||
<div class="episodes-grid">
|
||||
@for (episode of seasons[selectedSeason]; track $index) {
|
||||
@let episodeInfo = getEpisodeInfo(episode);
|
||||
<article
|
||||
class="episode-card"
|
||||
(click)="selectEpisode(episode)"
|
||||
>
|
||||
<div class="episode-card__thumbnail">
|
||||
@if (episodeInfo?.movie_image) {
|
||||
<img
|
||||
[src]="episodeInfo.movie_image"
|
||||
(error)="
|
||||
$event.target.src =
|
||||
'./assets/images/default-episode.png'
|
||||
"
|
||||
alt="Episode thumbnail"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<div class="episode-card__placeholder">
|
||||
<mat-icon>play_circle_outline</mat-icon>
|
||||
</div>
|
||||
}
|
||||
<div class="episode-card__overlay">
|
||||
<div class="episode-card__play">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__number">
|
||||
{{ episode.episode_num }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="episode-card__info">
|
||||
<h4 class="episode-card__title">
|
||||
{{ episode.episode_num }}. {{ episode.title }}
|
||||
</h4>
|
||||
@if (episodeInfo?.plot) {
|
||||
<p class="episode-card__plot">
|
||||
{{ episodeInfo.plot }}
|
||||
</p>
|
||||
}
|
||||
@if (episodeInfo?.duration) {
|
||||
<span class="episode-card__duration">
|
||||
{{ episodeInfo.duration }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,39 +1,2 @@
|
||||
:host {
|
||||
margin: 10px;
|
||||
display: block;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.seasons-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.season-item,
|
||||
.episode-item {
|
||||
min-height: 100px;
|
||||
justify-content: center;
|
||||
width: 150px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.episode-item {
|
||||
mat-card-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.episode-title {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.episode-cover {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
// Import shared Netflix-style from xtream-tauri
|
||||
@use '../../xtream-tauri/season-container/season-container.component.scss';
|
||||
@@ -1,13 +1,13 @@
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { XtreamSerieEpisode } from 'shared-interfaces';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
imports: [KeyValuePipe, MatCardModule],
|
||||
imports: [KeyValuePipe, MatIcon],
|
||||
})
|
||||
export class SeasonContainerComponent {
|
||||
@Input({ required: true }) seasons: Record<string, XtreamSerieEpisode[]>;
|
||||
@@ -16,6 +16,10 @@ export class SeasonContainerComponent {
|
||||
|
||||
selectedSeason: string;
|
||||
|
||||
compareSeasons(a: any, b: any): number {
|
||||
return Number(a.key) - Number(b.key);
|
||||
}
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason = seasonKey;
|
||||
}
|
||||
@@ -23,4 +27,17 @@ export class SeasonContainerComponent {
|
||||
selectEpisode(episode: any) {
|
||||
this.episodeClicked.emit(episode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get episode info - returns undefined if info is an empty array
|
||||
* (Xtream API returns [] when no metadata available instead of null/object)
|
||||
*/
|
||||
getEpisodeInfo(
|
||||
episode: XtreamSerieEpisode
|
||||
): XtreamSerieEpisodeInfo | undefined {
|
||||
if (Array.isArray(episode.info) || !episode.info) {
|
||||
return undefined;
|
||||
}
|
||||
return episode.info;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,97 @@
|
||||
<div
|
||||
class="container"
|
||||
[style.background]="
|
||||
item?.info?.backdrop_path && item?.info?.backdrop_path.length > 0
|
||||
? 'linear-gradient(to top, rgba(29,29,29,1) 0%, rgba(0,0,0,0.5) 100%), url(' +
|
||||
item?.info?.backdrop_path[0] +
|
||||
') repeat-x'
|
||||
: 'none'
|
||||
"
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<app-content-hero
|
||||
[title]="item.info.name"
|
||||
[description]="item.info.description"
|
||||
[posterUrl]="item.info.movie_image"
|
||||
[backdropUrl]="item.info.backdrop_path?.[0]"
|
||||
(backClicked)="goBack()"
|
||||
>
|
||||
<div class="image">
|
||||
@if (item.info?.movie_image) {
|
||||
<img
|
||||
[src]="item.info?.movie_image"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
alt="Movie poster"
|
||||
/>
|
||||
<!-- Tags: Year, Genre, Duration, Rating -->
|
||||
<ng-container hero-tags>
|
||||
@if (item.info?.releasedate) {
|
||||
<span class="details__tag">{{
|
||||
item.info.releasedate | slice: 0 : 4
|
||||
}}</span>
|
||||
}
|
||||
@if (item.info?.genre) {
|
||||
<span class="details__tag">{{ item.info.genre }}</span>
|
||||
}
|
||||
@if (item.info?.duration) {
|
||||
<span class="details__tag">{{ item.info.duration }}</span>
|
||||
}
|
||||
@if (item.info?.rating_imdb) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ item.info.rating_imdb }}
|
||||
</span>
|
||||
}
|
||||
@if (item.info?.country) {
|
||||
<span class="details__tag">{{ item.info.country }}</span>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<ng-container hero-meta>
|
||||
@if (item.info?.actors) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<span class="value">{{ item.info.actors }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.director) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
<span class="value">{{ item.info.director }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (item.info?.rating_kinopoisk) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{
|
||||
'XTREAM.KINOPOISK_RATING' | translate
|
||||
}}</span>
|
||||
<span class="value">{{ item.info.rating_kinopoisk }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<ng-container hero-actions>
|
||||
<button class="play-btn" (click)="playClicked.emit(item)">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
</button>
|
||||
@if (!isFavorite) {
|
||||
<button class="favorite-btn" (click)="toggleFavorite()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<div class="placeholder-cover"></div>
|
||||
}
|
||||
</div>
|
||||
<div class="details">
|
||||
<h2>{{ item.info.name }}</h2>
|
||||
@if (item.info.description) {
|
||||
<div>
|
||||
{{ item.info.description }}
|
||||
</div>
|
||||
}
|
||||
@if (item.info.releasedate) {
|
||||
<div class="label">{{ 'XTREAM.RELEASE_DATE' | translate }}:</div>
|
||||
{{ item.info.releasedate }}
|
||||
}
|
||||
@if (item.info.genre) {
|
||||
<div class="label">{{ 'XTREAM.GENRE' | translate }}:</div>
|
||||
{{ item.info.genre }}
|
||||
}
|
||||
@if (item.info.country) {
|
||||
<div class="label">{{ 'XTREAM.COUNTRY' | translate }}:</div>
|
||||
{{ item.info.country }}
|
||||
}
|
||||
@if (item.info.actors) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.ACTORS' | translate }}:</div>
|
||||
{{ item.info.actors }}
|
||||
</div>
|
||||
}
|
||||
@if (item.info.director) {
|
||||
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
|
||||
{{ item.info.director }}
|
||||
}
|
||||
@if (item.info.duration) {
|
||||
<div class="label">{{ 'XTREAM.DURATION' | translate }}:</div>
|
||||
{{ item.info.duration }}
|
||||
}
|
||||
@if (item.info.rating_imdb) {
|
||||
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
|
||||
{{ item.info.rating_imdb }}
|
||||
}
|
||||
@if (item.info.rating_kinopoisk) {
|
||||
<div class="label">
|
||||
{{ 'XTREAM.KINOPOISK_RATING' | translate }}:
|
||||
</div>
|
||||
{{ item.info.rating_kinopoisk }}
|
||||
}
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
mat-flat-button
|
||||
color="accent"
|
||||
(click)="playClicked.emit(item)"
|
||||
class="favorite-btn favorite-btn--active"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}</button
|
||||
>
|
||||
@if (!isFavorite) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>star_outline</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="accent"
|
||||
(click)="toggleFavorite()"
|
||||
>
|
||||
<mat-icon>star</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<!-- YouTube Trailer -->
|
||||
@if (item.info?.youtube_trailer) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<div class="trailer-wrapper">
|
||||
<iframe
|
||||
[src]="
|
||||
'https://www.youtube.com/embed/' +
|
||||
item.info.youtube_trailer | safe
|
||||
"
|
||||
title="YouTube video player"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (item.info.youtube_trailer) {
|
||||
<div class="youtube-trailer">
|
||||
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
|
||||
<iframe
|
||||
width="100%"
|
||||
height="450"
|
||||
[src]="
|
||||
'https://www.youtube.com/embed/' + item.info?.youtube_trailer
|
||||
| safe
|
||||
"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</app-content-hero>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Location, SlicePipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
@@ -6,24 +7,26 @@ import {
|
||||
Output,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ContentHeroComponent } from 'components';
|
||||
import { PlaylistsService } from 'services';
|
||||
import { XtreamVodDetails } from 'shared-interfaces';
|
||||
import { StalkerStore } from '../../stalker/stalker.store';
|
||||
import { SafePipe } from '../../xtream-tauri/vod-details/safe.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-vod-details',
|
||||
templateUrl: './vod-details.component.html',
|
||||
styleUrls: ['../detail-view.scss'],
|
||||
styleUrls: ['../../xtream-tauri/detail-view.scss'],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
ContentHeroComponent,
|
||||
MatIcon,
|
||||
MatProgressSpinnerModule,
|
||||
SafePipe,
|
||||
SlicePipe,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
@@ -34,12 +37,19 @@ export class VodDetailsComponent implements OnInit {
|
||||
@Output() playClicked = new EventEmitter<XtreamVodDetails>();
|
||||
@Output() removeFromFavoritesClicked = new EventEmitter<number>();
|
||||
|
||||
private location = inject(Location);
|
||||
private playlistService = inject(PlaylistsService);
|
||||
private route = inject(ActivatedRoute);
|
||||
private stalkerStore = inject(StalkerStore);
|
||||
private portalId =
|
||||
this.route.snapshot.paramMap.get('id') ??
|
||||
this.route.parent.snapshot.params.id;
|
||||
|
||||
/** Check if this is a Stalker item (has cmd property) */
|
||||
private get isStalker(): boolean {
|
||||
return !!(this.item as any)?.cmd;
|
||||
}
|
||||
|
||||
isFavorite = false;
|
||||
isLoading = false;
|
||||
|
||||
@@ -102,4 +112,14 @@ export class VodDetailsComponent implements OnInit {
|
||||
}
|
||||
this.isFavorite = !this.isFavorite;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
if (this.isStalker) {
|
||||
// Stalker mode: clear selectedItem to return to category view
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
} else {
|
||||
// Xtream mode: use browser history navigation
|
||||
this.location.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,25 +26,27 @@ export interface XtreamSerieEpisode {
|
||||
episode_num: number;
|
||||
title: string;
|
||||
container_extension: string;
|
||||
info: {
|
||||
tmdb_id: number;
|
||||
releasedate: string;
|
||||
plot: string;
|
||||
duration_secs: number;
|
||||
duration: string;
|
||||
movie_image: string;
|
||||
video: Record<string, string>; // TODO
|
||||
audio: Record<string, string>; // TODO
|
||||
bitrate: number;
|
||||
rating: number;
|
||||
season: string;
|
||||
};
|
||||
info: XtreamSerieEpisodeInfo | []; // Can be empty array when no metadata available
|
||||
custom_sid: string;
|
||||
added: string;
|
||||
season: number;
|
||||
direct_source: string;
|
||||
}
|
||||
|
||||
export interface XtreamSerieEpisodeInfo {
|
||||
tmdb_id?: number;
|
||||
releasedate?: string;
|
||||
plot?: string;
|
||||
duration_secs?: number;
|
||||
duration?: string;
|
||||
movie_image?: string;
|
||||
video?: Record<string, string>; // TODO
|
||||
audio?: Record<string, string>; // TODO
|
||||
bitrate?: number;
|
||||
rating?: number;
|
||||
season?: string;
|
||||
}
|
||||
|
||||
export interface XtreamSerieSeason {
|
||||
air_date: string;
|
||||
episode_count: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './lib/audio-player/audio-player.component';
|
||||
export * from './lib/channel-list-container/channel-list-container.component';
|
||||
export * from './lib/confirm-dialog/confirm-dialog.component';
|
||||
export * from './lib/confirm-dialog/dialog.service';
|
||||
export * from './lib/content-hero/content-hero.component';
|
||||
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
|
||||
export * from './lib/epg-list/epg-list-item/epg-list-item.component';
|
||||
export * from './lib/epg-list/epg-list.component';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- Hero Section with Backdrop -->
|
||||
<section class="hero">
|
||||
<!-- Back Button -->
|
||||
<button class="hero__back-button" (click)="onBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Backdrop Image -->
|
||||
<div
|
||||
class="hero__backdrop"
|
||||
[class.hero__backdrop--blurred]="!backdropUrl"
|
||||
[style.backgroundImage]="
|
||||
backdropUrl
|
||||
? 'url(' + backdropUrl + ')'
|
||||
: posterUrl
|
||||
? 'url(' + posterUrl + ')'
|
||||
: 'none'
|
||||
"
|
||||
></div>
|
||||
|
||||
<!-- Gradient Vignette Overlay -->
|
||||
<div class="hero__vignette"></div>
|
||||
|
||||
<!-- Hero Content -->
|
||||
<div class="hero__content">
|
||||
<!-- Poster Card -->
|
||||
<div class="poster">
|
||||
@if (posterUrl) {
|
||||
<img
|
||||
class="poster__image"
|
||||
[src]="posterUrl"
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
alt="Poster"
|
||||
/>
|
||||
} @else {
|
||||
<div class="poster__placeholder">No Poster</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="details">
|
||||
<h1 class="details__title">{{ title }}</h1>
|
||||
|
||||
<!-- Tags Slot -->
|
||||
<div class="details__tags">
|
||||
<ng-content select="[hero-tags]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@if (description) {
|
||||
<p class="details__description">
|
||||
{{ description }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<!-- Metadata Grid Slot -->
|
||||
<div class="details__meta">
|
||||
<ng-content select="[hero-meta]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons Slot -->
|
||||
<div class="action-buttons">
|
||||
<ng-content select="[hero-actions]"></ng-content>
|
||||
</div>
|
||||
|
||||
<!-- Additional Content (Seasons, Trailers, etc.) -->
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,473 @@
|
||||
// ============================================================================
|
||||
// Netflix-style Detail View - Cinematic Streaming Design
|
||||
// ============================================================================
|
||||
|
||||
:host {
|
||||
--poster-width: 220px;
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--content-max-width: 1400px;
|
||||
|
||||
// Light theme colors (default - no .dark-theme class on body)
|
||||
--hero-gradient-start: rgba(255, 250, 245, 0);
|
||||
--hero-gradient-mid: rgba(255, 250, 245, 0.75);
|
||||
--hero-gradient-end: rgba(255, 250, 245, 1);
|
||||
--surface-bg: #fffaf5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: rgba(26, 26, 26, 0.75);
|
||||
--text-muted: rgba(26, 26, 26, 0.5);
|
||||
--accent-color: #b81d24;
|
||||
--tag-bg: rgba(0, 0, 0, 0.06);
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
background: var(--surface-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
// Dark theme overrides - cinematic dark mode
|
||||
:host-context(.dark-theme) {
|
||||
--hero-gradient-start: rgba(20, 20, 20, 0);
|
||||
--hero-gradient-mid: rgba(20, 20, 20, 0.7);
|
||||
--hero-gradient-end: rgba(20, 20, 20, 1);
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.5);
|
||||
--accent-color: #e50914;
|
||||
--tag-bg: rgba(255, 255, 255, 0.1);
|
||||
--tag-border: rgba(255, 255, 255, 0.2);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Hero Section - Full container with backdrop
|
||||
// ============================================================================
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(
|
||||
--surface-bg
|
||||
); // Solid background prevents any bleed-through
|
||||
|
||||
// Floating back button over backdrop
|
||||
&__back-button {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
// Backdrop image layer - fixed at top, never repeats
|
||||
&__backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: auto; // Only use height, not bottom
|
||||
height: 60%;
|
||||
background-size: cover;
|
||||
background-position: center 20%;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
|
||||
// Fallback blur effect when using poster as backdrop
|
||||
&--blurred {
|
||||
filter: blur(40px) saturate(1.3);
|
||||
transform: scale(1.4);
|
||||
height: 65%;
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient overlays for text legibility
|
||||
&__vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0; // Full height to cover everything
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent 35%,
|
||||
var(--surface-bg) 55%,
|
||||
var(--surface-bg) 100%
|
||||
);
|
||||
|
||||
// Top area gradient for text legibility over backdrop
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60%;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
var(--hero-gradient-end) 0%,
|
||||
var(--hero-gradient-mid) 50%,
|
||||
var(--hero-gradient-start) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// Side fade for poster area
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 50%;
|
||||
width: 40%;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--hero-gradient-mid) 0%,
|
||||
var(--hero-gradient-start) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrollable content area
|
||||
&__content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
padding: 140px 40px 60px;
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
// Hide scrollbar but keep functionality
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Poster Card - Floating with shadow
|
||||
// ============================================================================
|
||||
|
||||
.poster {
|
||||
flex-shrink: 0;
|
||||
width: var(--poster-width);
|
||||
align-self: flex-start;
|
||||
|
||||
&:hover .poster__image {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--poster-shadow);
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
box-shadow: var(--poster-shadow);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Details Section - Title, metadata, description
|
||||
// ============================================================================
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 24px;
|
||||
|
||||
&__title {
|
||||
font-size: clamp(1.75rem, 4vw, 3rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin: 0 0 16px 0;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// These styles are for Projected Content, we need to ensure they apply
|
||||
// using ::ng-deep or just assume the global styles will handle them?
|
||||
// Actually, since we project content, the styles defined here might NOT apply to the projected content
|
||||
// unless we use ::ng-deep or the content itself has these styles.
|
||||
// However, since we are moving the "shell", the container classes (.details__tags) are here.
|
||||
// The inner items (.details__tag) will be in the projected content.
|
||||
// So we need ::ng-deep here for .details__tag.
|
||||
|
||||
::ng-deep .details__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: var(--tag-bg);
|
||||
border: 1px solid var(--tag-border);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&--rating {
|
||||
color: #ffd700;
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
max-width: 600px;
|
||||
|
||||
// Truncate long descriptions
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
// Similar issue for meta items
|
||||
::ng-deep .details__meta-item {
|
||||
font-size: 0.875rem;
|
||||
|
||||
.label {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin-bottom: 2px;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Buttons - Play, Favorite
|
||||
// ============================================================================
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0;
|
||||
|
||||
// Use ::ng-deep to style the projected buttons
|
||||
::ng-deep .play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: #fff;
|
||||
color: #141414;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .favorite-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
background: rgba(109, 109, 110, 0.7);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(109, 109, 110, 0.5);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: rgba(229, 9, 20, 0.8);
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 9, 20, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Mobile Responsive - Option A: Keep poster grid layout
|
||||
// ============================================================================
|
||||
|
||||
@media (max-width: 800px) {
|
||||
:host {
|
||||
--poster-width: 120px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
&__backdrop {
|
||||
height: 45%;
|
||||
|
||||
&--blurred {
|
||||
height: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__vignette {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
transparent 25%,
|
||||
var(--surface-bg) 45%,
|
||||
var(--surface-bg) 100%
|
||||
);
|
||||
|
||||
&::before {
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 60px 20px 40px;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.poster {
|
||||
// Mobile poster adjustments - none needed
|
||||
}
|
||||
|
||||
.details {
|
||||
&__title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
gap: 12px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
width: 100%;
|
||||
|
||||
::ng-deep .play-btn,
|
||||
::ng-deep .favorite-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__content {
|
||||
animation: fadeInUp 0.6s ease-out 0.2s both;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-content-hero',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule, MatButtonModule],
|
||||
templateUrl: './content-hero.component.html',
|
||||
styleUrls: ['./content-hero.component.scss'],
|
||||
})
|
||||
export class ContentHeroComponent {
|
||||
@Input() title: string | undefined;
|
||||
@Input() description: string | undefined;
|
||||
@Input() posterUrl: string | undefined;
|
||||
@Input() backdropUrl: string | undefined;
|
||||
|
||||
@Output() backClicked = new EventEmitter<void>();
|
||||
|
||||
onBack(): void {
|
||||
this.backClicked.emit();
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,24 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, effect, inject, input, output } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
PlaylistActions,
|
||||
selectActiveTypeFilters,
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from 'm3u-state';
|
||||
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
|
||||
import { BehaviorSubject, combineLatest, map, tap } from 'rxjs';
|
||||
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||
import { DatabaseService, DataService, SortService } from 'services';
|
||||
import { PLAYLIST_UPDATE, PlaylistMeta } from 'shared-interfaces';
|
||||
import { DialogService } from '../confirm-dialog/dialog.service';
|
||||
import { PlaylistType } from '../add-playlist-menu/add-playlist-menu.component';
|
||||
import { DialogService } from '../confirm-dialog/dialog.service';
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
|
||||
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
|
||||
@@ -32,12 +31,10 @@ import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
EmptyStateComponent,
|
||||
MatIcon,
|
||||
MatInputModule,
|
||||
MatListModule,
|
||||
NgxSkeletonLoaderComponent,
|
||||
PlaylistItemComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class RecentPlaylistsComponent {
|
||||
@@ -112,7 +109,7 @@ export class RecentPlaylistsComponent {
|
||||
|
||||
return {
|
||||
playlists: sortedPlaylists,
|
||||
totalCount: playlists.length
|
||||
totalCount: playlists.length,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user