mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge pull request #712 from 4gray/style/stalker-porta-series-view
feat: enhance favorites button and series view with loading states and improved styling
This commit is contained in:
9 files changed
+353
-489
No files matched your search
@@ -1,12 +1,12 @@
|
||||
@let isFavorite = isFavorite$ | async;
|
||||
@if (isFavorite === false) {
|
||||
<button mat-flat-button color="accent" (click)="addToFavorites()">
|
||||
<mat-icon>star_outline</mat-icon>
|
||||
<button class="favorite-btn" (click)="addToFavorites()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'PORTALS.ADD_TO_FAVORITES' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<button mat-stroked-button color="accent" (click)="removeFromFavorites()">
|
||||
<mat-icon>star</mat-icon>
|
||||
<button class="favorite-btn favorite-btn--active" (click)="removeFromFavorites()">
|
||||
<mat-icon>check</mat-icon>
|
||||
{{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }}
|
||||
</button>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, inject, input, output } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, map, switchMap } from 'rxjs';
|
||||
@@ -10,7 +9,8 @@ import { StalkerStore } from '../stalker.store';
|
||||
@Component({
|
||||
selector: 'app-favorites-button',
|
||||
templateUrl: './favorites-button.component.html',
|
||||
imports: [AsyncPipe, MatButtonModule, MatIconModule, TranslateModule],
|
||||
styleUrls: ['./favorites-button.component.scss'],
|
||||
imports: [AsyncPipe, MatIconModule, TranslateModule],
|
||||
})
|
||||
export class FavoritesButtonComponent {
|
||||
private playlistService = inject(PlaylistsService);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
@let seasons = regularSeasons();
|
||||
|
||||
@if (displayItem(); as serial) {
|
||||
<app-content-hero
|
||||
[title]="serial.info.name"
|
||||
@@ -60,95 +58,20 @@
|
||||
/>
|
||||
</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>
|
||||
<!-- Seasons & Episodes -->
|
||||
@if (mappedSeasons()) {
|
||||
<app-season-container
|
||||
[seasons]="mappedSeasons()"
|
||||
[isLoading]="
|
||||
isVodSeriesSeasonsLoading() ||
|
||||
(seasonContainer?.selectedSeason &&
|
||||
isCurrentSeasonLoading(seasonContainer.selectedSeason))
|
||||
"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(episodeClicked)="onEpisodeClicked($event)"
|
||||
#seasonContainer
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
<div class="episodes">
|
||||
@for (episode of item.series; track $index) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="playEpisodeClicked(episode, item.cmd)"
|
||||
>
|
||||
{{ 'XTREAM.EPISODE' | translate }}
|
||||
{{ episode }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</app-content-hero>
|
||||
}
|
||||
|
||||
@@ -6,13 +6,12 @@ import {
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
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';
|
||||
import { SeasonContainerComponent } from '../../xtream-tauri/season-container/season-container.component';
|
||||
import { XtreamSerieEpisode } from 'shared-interfaces';
|
||||
|
||||
/**
|
||||
* VOD series season with episodes loaded dynamically
|
||||
@@ -51,10 +50,8 @@ interface StalkerSeriesSeason {
|
||||
imports: [
|
||||
FavoritesButtonComponent,
|
||||
ContentHeroComponent,
|
||||
MatButton,
|
||||
MatDivider,
|
||||
MatProgressSpinner,
|
||||
TranslatePipe,
|
||||
SeasonContainerComponent,
|
||||
],
|
||||
})
|
||||
export class StalkerSeriesViewComponent {
|
||||
@@ -151,89 +148,180 @@ export class StalkerSeriesViewComponent {
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggle season expansion and load episodes if needed (for VOD series)
|
||||
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
|
||||
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
|
||||
*/
|
||||
async toggleSeasonExpanded(season: VodSeriesSeason) {
|
||||
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
|
||||
() => {
|
||||
const mapped: Record<string, XtreamSerieEpisode[]> = {};
|
||||
|
||||
if (this.isVodSeries()) {
|
||||
// Map VOD Series (async loaded)
|
||||
this.vodSeriesSeasons().forEach((season) => {
|
||||
const seasonKey =
|
||||
season.season_number || season.name || season.id;
|
||||
// Map existing episodes or empty array
|
||||
mapped[seasonKey] = (season.episodes || []).map(
|
||||
(ep) =>
|
||||
({
|
||||
episode_num:
|
||||
ep.series_number || ep.episode_num || 0,
|
||||
title:
|
||||
ep.name ||
|
||||
`Episode ${ep.series_number || ep.episode_num}`,
|
||||
container_extension: 'mpg',
|
||||
info: {
|
||||
movie_image:
|
||||
ep.cover ||
|
||||
this.displayItem().info.movie_image,
|
||||
plot: ep.description || '',
|
||||
duration: ep.duration
|
||||
? `${ep.duration} min`
|
||||
: '',
|
||||
},
|
||||
id: ep.id,
|
||||
custom_sid: 'vod-series', // marker
|
||||
}) as unknown as XtreamSerieEpisode
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Map Regular Series (just numbers)
|
||||
(this.regularSeasons() || []).forEach((season, index) => {
|
||||
const seasonKey = (index + 1).toString();
|
||||
mapped[seasonKey] = (season.series || []).map(
|
||||
(epNum) =>
|
||||
({
|
||||
episode_num: epNum,
|
||||
title: `Episode ${epNum}`,
|
||||
container_extension: '',
|
||||
info: {
|
||||
movie_image:
|
||||
this.displayItem().info.movie_image,
|
||||
},
|
||||
custom_sid: 'regular-series', // marker
|
||||
id: season.cmd, // Store cmd in id for playEpisodeClicked
|
||||
}) as unknown as XtreamSerieEpisode
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles season selection from the container.
|
||||
* For VOD Series, triggers lazy loading of episodes.
|
||||
*/
|
||||
onSeasonSelected(seasonKey: string) {
|
||||
if (!this.isVodSeries()) return;
|
||||
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const season = seasons.find(
|
||||
(s) => (s.season_number || s.name || s.id) == seasonKey
|
||||
);
|
||||
|
||||
if (season && (!season.episodes || season.episodes.length === 0)) {
|
||||
this.loadEpisodesForSeason(season);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads episodes for a specific VOD season
|
||||
*/
|
||||
async loadEpisodesForSeason(season: VodSeriesSeason) {
|
||||
// Set loading state in local signal
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const index = seasons.findIndex((s) => s.id === season.id);
|
||||
if (index === -1) return;
|
||||
|
||||
const updatedSeasons = [...seasons];
|
||||
const currentSeason = { ...updatedSeasons[index] };
|
||||
updatedSeasons[index] = { ...updatedSeasons[index], isLoading: true };
|
||||
this.vodSeriesSeasons.set(updatedSeasons);
|
||||
|
||||
// Toggle expansion
|
||||
currentSeason.isExpanded = !currentSeason.isExpanded;
|
||||
try {
|
||||
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
|
||||
season.video_id,
|
||||
season.id
|
||||
);
|
||||
|
||||
// Load episodes if expanding and not already loaded
|
||||
if (
|
||||
currentSeason.isExpanded &&
|
||||
(!currentSeason.episodes || currentSeason.episodes.length === 0)
|
||||
) {
|
||||
currentSeason.isLoading = true;
|
||||
updatedSeasons[index] = currentSeason;
|
||||
this.vodSeriesSeasons.set(updatedSeasons);
|
||||
|
||||
try {
|
||||
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
|
||||
currentSeason.video_id,
|
||||
currentSeason.id
|
||||
);
|
||||
// Update season with loaded episodes
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const seasonIndex = newSeasons.findIndex(
|
||||
(s) => s.id === season.id
|
||||
);
|
||||
if (seasonIndex !== -1) {
|
||||
newSeasons[seasonIndex] = {
|
||||
...newSeasons[seasonIndex],
|
||||
episodes: episodes,
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StalkerSeriesViewComponent] Failed to load episodes:',
|
||||
error
|
||||
);
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const seasonIndex = newSeasons.findIndex(
|
||||
(s) => s.id === season.id
|
||||
);
|
||||
if (seasonIndex !== -1) {
|
||||
newSeasons[seasonIndex] = {
|
||||
...newSeasons[seasonIndex],
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
// Update with loaded episodes
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
|
||||
if (newIndex !== -1) {
|
||||
newSeasons[newIndex] = {
|
||||
...newSeasons[newIndex],
|
||||
episodes: episodes,
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StalkerSeriesViewComponent] Failed to load episodes:',
|
||||
error
|
||||
);
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const newIndex = newSeasons.findIndex((s) => s.id === season.id);
|
||||
if (newIndex !== -1) {
|
||||
newSeasons[newIndex] = {
|
||||
...newSeasons[newIndex],
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current selected season is loading
|
||||
*/
|
||||
isCurrentSeasonLoading(seasonKey: string): boolean {
|
||||
if (!this.isVodSeries()) return false;
|
||||
const season = this.vodSeriesSeasons().find(
|
||||
(s) => (s.season_number || s.name || s.id) == seasonKey
|
||||
);
|
||||
return season?.isLoading || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles episode click from the container
|
||||
*/
|
||||
onEpisodeClicked(episode: XtreamSerieEpisode) {
|
||||
if (episode.custom_sid === 'vod-series') {
|
||||
// It's a VOD series episode
|
||||
// We reconstructed the episode object, but playVodSeriesEpisode expects the raw object
|
||||
// or at least { id, name, series_number }
|
||||
this.playVodSeriesEpisode({
|
||||
id: episode.id,
|
||||
name: episode.title,
|
||||
series_number: episode.episode_num,
|
||||
});
|
||||
} else {
|
||||
updatedSeasons[index] = currentSeason;
|
||||
this.vodSeriesSeasons.set(updatedSeasons);
|
||||
// Regular series
|
||||
// episode.id contains the 'cmd' from mapping
|
||||
this.playEpisodeClicked(episode.episode_num, episode.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play episode - handles both regular series and VOD series
|
||||
*/
|
||||
playEpisodeClicked(episode: any, cmd?: string) {
|
||||
playEpisodeClicked(episodeNum: any, cmd?: string) {
|
||||
const item = this.displayItem();
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
cmd,
|
||||
item.info.name,
|
||||
item.info.movie_image,
|
||||
episode
|
||||
episodeNum
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play VOD series episode
|
||||
* For VOD series, we need to use a different cmd format
|
||||
*/
|
||||
playVodSeriesEpisode(episode: any) {
|
||||
const item = this.displayItem();
|
||||
// For VOD series episodes, the cmd is: /media/file_{episode.id}.mpg
|
||||
const cmd = `/media/file_${episode.id}.mpg`;
|
||||
const episodeName = episode.name || `Episode ${episode.series_number}`;
|
||||
|
||||
@@ -261,7 +349,6 @@ export class StalkerSeriesViewComponent {
|
||||
}
|
||||
|
||||
goBack() {
|
||||
// Clear selectedItem to return to category view
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,13 @@ export class StalkerShellComponent implements OnDestroy {
|
||||
playlistId: this.route.snapshot.params['id'],
|
||||
})
|
||||
);
|
||||
this.stalkerStore.setSelectedContentType('vod');
|
||||
|
||||
const childRoute = this.route.snapshot.firstChild;
|
||||
const path = childRoute?.url[0]?.path;
|
||||
const validTypes = ['vod', 'series', 'itv'];
|
||||
const initialType = validTypes.includes(path) ? (path as any) : 'vod';
|
||||
|
||||
this.stalkerStore.setSelectedContentType(initialType);
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
effect(() => {
|
||||
this.stalkerStore.setCurrentPlaylist(this.currentPlaylist());
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (!selectedSeason) {
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
} @else if (!selectedSeason) {
|
||||
<!-- Seasons Grid -->
|
||||
<div class="seasons-grid">
|
||||
@for (
|
||||
|
||||
@@ -1,143 +1,97 @@
|
||||
// ============================================================================
|
||||
// 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;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
min-height: 32px;
|
||||
|
||||
.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;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Seasons Grid
|
||||
// ============================================================================
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 30px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Grid Layouts
|
||||
.seasons-grid,
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.seasons-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.episodes-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
// Season Card
|
||||
.season-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px 12px;
|
||||
min-height: 80px;
|
||||
background: rgba(50, 50, 50, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
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;
|
||||
}
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
background: var(--card-bg-hover);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||||
|
||||
&::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
|
||||
.season-card__arrow {
|
||||
opacity: 1;
|
||||
@@ -146,83 +100,59 @@
|
||||
}
|
||||
|
||||
&__number {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #fff; // Force white
|
||||
font-size: 1.75rem !important;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.05);
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6); // Force light
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.9375rem !important;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__episodes {
|
||||
font-size: 0.6875rem;
|
||||
color: rgba(255, 255, 255, 0.7); // Force light
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateX(8px) translateY(-50%);
|
||||
opacity: 0;
|
||||
color: var(--accent-color);
|
||||
transition: all 0.25s ease;
|
||||
transform: translateX(-5px);
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-muted);
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&--has-episodes {
|
||||
.season-card__number {
|
||||
color: var(--accent-color);
|
||||
opacity: 0.4; // More subtle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Episodes Grid
|
||||
// ============================================================================
|
||||
|
||||
.episodes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// Episode Card
|
||||
.episode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&:hover {
|
||||
background: var(--card-bg-hover);
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
.episode-card__overlay {
|
||||
opacity: 1;
|
||||
@@ -231,24 +161,18 @@
|
||||
.episode-card__play {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.episode-card__thumbnail img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,208 +182,92 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #3a3a3a 0%, #2a2a2a 100%);
|
||||
background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%);
|
||||
color: var(--text-muted);
|
||||
|
||||
mat-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-muted);
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&__play {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
transform: scale(0.8);
|
||||
transition: transform 0.25s ease;
|
||||
box-shadow: 0 4px 20px rgba(229, 9, 20, 0.4);
|
||||
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #fff;
|
||||
margin-left: 3px; // Optical centering for play icon
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
&__number {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
bottom: 6px;
|
||||
right: 6px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #fff; // Always white on dark badge
|
||||
color: white;
|
||||
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
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 4px 0;
|
||||
color: var(--text-primary);
|
||||
color: #fff; // Force white in dark mode for readability
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Truncate long titles
|
||||
&__plot {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 8px 0;
|
||||
line-height: 1.4;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
color: var(--text-muted);
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,21 @@
|
||||
import { KeyValuePipe } from '@angular/common';
|
||||
import { Component, EventEmitter, Output, input } from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { XtreamSerieEpisode, XtreamSerieEpisodeInfo } from 'shared-interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-season-container',
|
||||
templateUrl: './season-container.component.html',
|
||||
styleUrls: ['./season-container.component.scss'],
|
||||
imports: [KeyValuePipe, MatIcon],
|
||||
imports: [KeyValuePipe, MatIcon, MatProgressSpinnerModule],
|
||||
})
|
||||
export class SeasonContainerComponent {
|
||||
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
|
||||
readonly isLoading = input<boolean>(false);
|
||||
|
||||
@Output() episodeClicked = new EventEmitter<any>();
|
||||
@Output() seasonSelected = new EventEmitter<string>();
|
||||
|
||||
selectedSeason: string;
|
||||
|
||||
@@ -22,6 +25,7 @@ export class SeasonContainerComponent {
|
||||
|
||||
selectSeason(seasonKey: string) {
|
||||
this.selectedSeason = seasonKey;
|
||||
this.seasonSelected.emit(seasonKey);
|
||||
}
|
||||
|
||||
selectEpisode(episode: XtreamSerieEpisode) {
|
||||
|
||||
Reference in new issue
Block a user