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:
4gray authored and GitHub committed 2026-01-06 17:58:13 +01:00
commit 834841b549
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) {