From afad80e61b66111cb045853060019932d2867c8a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:54:54 +0100 Subject: [PATCH 01/12] feat(ui): add ContentHero component and detail view styles Add a new standalone ContentHero component (template and TS) that provides a cinematic hero section with backdrop, poster, title, description, tags/meta/action slots, and a back button event emitter. The component chooses backdrop or poster as the background, falls back to a default poster image on load error, and exposes inputs for title, description, posterUrl, and backdropUrl. Introduce extensive cinematic styles for the detail view: - define CSS custom properties for light and dark themes, poster size, shadows, and color tokens - implement gradient vignette, layout sizing, and theming via :host and :host-context(.dark-theme) - change container sizing behavior to full-height and hide overflow These changes create a reusable hero UI and themed detail-page styles to support a Netflix-style streaming detail experience. --- .../web/src/app/xtream-tauri/detail-view.scss | 440 ++++++++++++---- libs/ui/components/src/index.ts | 1 + .../content-hero/content-hero.component.html | 72 +++ .../content-hero/content-hero.component.scss | 473 ++++++++++++++++++ .../content-hero/content-hero.component.ts | 24 + 5 files changed, 900 insertions(+), 110 deletions(-) create mode 100644 libs/ui/components/src/lib/content-hero/content-hero.component.html create mode 100644 libs/ui/components/src/lib/content-hero/content-hero.component.scss create mode 100644 libs/ui/components/src/lib/content-hero/content-hero.component.ts diff --git a/apps/web/src/app/xtream-tauri/detail-view.scss b/apps/web/src/app/xtream-tauri/detail-view.scss index fabb0fd35..88895fa10 100644 --- a/apps/web/src/app/xtream-tauri/detail-view.scss +++ b/apps/web/src/app/xtream-tauri/detail-view.scss @@ -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; +} \ No newline at end of file diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index a74ec9623..1ba4a1f71 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html new file mode 100644 index 000000000..db95d680b --- /dev/null +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -0,0 +1,72 @@ + +
+ + + + +
+ + +
+ + +
+ +
+ @if (posterUrl) { + Poster + } @else { +
No Poster
+ } +
+ + +
+

{{ title }}

+ + +
+ +
+ + + @if (description) { +

+ {{ description }} +

+ } + + +
+ +
+ + +
+ +
+ + + +
+
+
diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss new file mode 100644 index 000000000..fbe107b5d --- /dev/null +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -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; +} diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts new file mode 100644 index 000000000..36bad6e15 --- /dev/null +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -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(); + + onBack(): void { + this.backClicked.emit(); + } +} From f4625698790e70cea724a27f66ac89b36e7966a3 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:54:03 +0100 Subject: [PATCH 02/12] feat(types): allow empty episode info and export interface --- .../src/lib/xtream-serie-details.interface.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts index 137999310..1da99e1db 100644 --- a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts @@ -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; // TODO - audio: Record; // 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; // TODO + audio?: Record; // TODO + bitrate?: number; + rating?: number; + season?: string; +} + export interface XtreamSerieSeason { air_date: string; episode_count: number; From e2f3583e13c8853c080ef8c5ba96d4818d405420 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:54:30 +0100 Subject: [PATCH 03/12] feat: split header menus for desktop and mobile --- .../components/header/header.component.html | 98 ++++++++++++++++- .../components/header/header.component.scss | 101 ++++++++++++++---- 2 files changed, 176 insertions(+), 23 deletions(-) diff --git a/apps/web/src/app/shared/components/header/header.component.html b/apps/web/src/app/shared/components/header/header.component.html index 3cdf2f00f..f6ea506ff 100644 --- a/apps/web/src/app/shared/components/header/header.component.html +++ b/apps/web/src/app/shared/components/header/header.component.html @@ -21,7 +21,9 @@ (input)="onSearchQueryUpdate(searchInput.value)" /> - diff --git a/apps/web/src/app/shared/components/header/header.component.scss b/apps/web/src/app/shared/components/header/header.component.scss index 1f43821b3..45d92f2b2 100644 --- a/apps/web/src/app/shared/components/header/header.component.scss +++ b/apps/web/src/app/shared/components/header/header.component.scss @@ -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; } } From 90564a7d2462902b6fdb5a0ad0c7971bf0c82721 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:54:40 +0100 Subject: [PATCH 04/12] fix(grid-list): use fallback images and simplified attrs --- .../grid-list/grid-list.component.ts | 36 ++++++------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/shared/components/grid-list/grid-list.component.ts b/apps/web/src/app/shared/components/grid-list/grid-list.component.ts index 8404a4b23..87536491a 100644 --- a/apps/web/src/app/shared/components/grid-list/grid-list.component.ts +++ b/apps/web/src/app/shared/components/grid-list/grid-list.component.ts @@ -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); @let poster = i.poster_url ?? i.cover; - @if (poster) { - logo - } @else { - logo - } + " + loading="lazy" + alt="logo" + /> @let rating = i.rating ?? i.rating_imdb; @if (rating) {
Date: Mon, 5 Jan 2026 21:55:19 +0100 Subject: [PATCH 05/12] refactor(ui): tidy imports and cleanup RecentPlaylists --- .../recent-playlists/recent-playlists.component.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts index a38c25db2..b291f3b87 100644 --- a/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/ui/components/src/lib/recent-playlists/recent-playlists.component.ts @@ -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, }; }) ); From 3f29aa1b0db3becc0cd704faca411f59b05b710a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:56:19 +0100 Subject: [PATCH 06/12] style: create vod and serial hero layout --- .../stalker-series-view.component.html | 265 +++++++++--------- .../stalker-series-view.component.ts | 33 ++- .../serial-details.component.html | 135 ++++----- .../serial-details.component.ts | 24 +- .../vod-details/vod-details.component.html | 211 +++++++------- .../vod-details/vod-details.component.ts | 11 +- .../vod-details/vod-details.component.html | 202 ++++++------- .../vod-details/vod-details.component.ts | 26 +- 8 files changed, 447 insertions(+), 460 deletions(-) diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html index d10cfe418..2f5e65d35 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.html @@ -1,159 +1,154 @@ -@let seasons = seasonsData(); -
- @if (displayItem(); as serial) { -
- @if (serial.info.movie_image) { - Series Cover - } @else { -
- } -
+@let seasons = regularSeasons(); -
-

{{ serial.info.name }}

-
- {{ serial.info.description }} -
- @if (serial.info.director) { -
-
- {{ 'XTREAM.DIRECTOR' | translate }}: -
- {{ serial.info.director }} -
- } - @if (serial.info.actors) { -
-
{{ 'XTREAM.ACTORS' | translate }}:
- {{ serial.info.actors }} -
- } +@if (displayItem(); as serial) { + + + @if (serial.info.releasedate || serial.info.year) { -
-
{{ 'XTREAM.YEAR' | translate }}:
- {{ serial.info.releasedate || serial.info.year }} -
+ {{ + serial.info.releasedate || serial.info.year + }} } @if (serial.info.genre || serial.info.genres_str) { -
-
{{ 'XTREAM.GENRE' | translate }}:
- {{ serial.info.genre || serial.info.genres_str }} -
+ {{ + serial.info.genre || serial.info.genres_str + }} } @if (serial.info.rating_imdb) { -
-
- {{ 'XTREAM.IMDB_RATING' | translate }}: -
- {{ serial.info.rating_imdb }} -
+ + ⭐ {{ serial.info.rating_imdb }} + } @if (serial.info.rating_kinopoisk) { -
-
- {{ 'XTREAM.KINOPOISK_RATING' | translate }}: -
- {{ serial.info.rating_kinopoisk }} + + KP {{ serial.info.rating_kinopoisk }} + + } + + + + + @if (serial.info.actors) { +
+ {{ + 'XTREAM.ACTORS' | translate + }} + {{ serial.info.actors }}
} -
- + @if (serial.info.director) { +
+ {{ + 'XTREAM.DIRECTOR' | translate + }} + {{ serial.info.director }} +
+ } + + + + + + + + + @if (isVodSeries() && isVodSeriesSeasonsLoading()) { +
+ + {{ 'PORTALS.LOADING_SEASONS' | translate }}
-
- } -
+ } - -@if (isVodSeries() && isVodSeriesSeasonsLoading()) { -
- - {{ 'PORTALS.LOADING_SEASONS' | translate }} -
-} - - -@if (!isVodSeries() && isSerialSeasonsLoading()) { -
- - {{ 'PORTALS.LOADING_SEASONS' | translate }} -
-} - -
- @if (isVodSeries()) { - - @for (item of vodSeriesSeasons(); track item.id) { - -
-

{{ item.name }}

- {{ - item.isExpanded ? '▼' : '►' - }} + + @if (!isVodSeries() && isSerialSeasonsLoading()) { +
+ + {{ 'PORTALS.LOADING_SEASONS' | translate }}
+ } - @if (item.isExpanded) { - @if (item.isLoading) { -
- +
+ @if (isVodSeries()) { + + @for (item of vodSeriesSeasons(); track item.id) { + +
+

{{ item.name }}

+ {{ + item.isExpanded ? '▼' : '►' + }} +
+ + @if (item.isExpanded) { + @if (item.isLoading) { +
+ +
+ } @else { +
+ @for ( + episode of item.episodes; + track episode.id ?? $index + ) { + + } + @if ( + !item.episodes || item.episodes.length === 0 + ) { + {{ + 'PORTALS.NO_EPISODES' | translate + }} + } +
+ } + } + } + } @else { + + @for (item of seasons; track item.id ?? $index) { + +
+

{{ item.name }}

- } @else {
- @for ( - episode of item.episodes; - track episode.id || $index - ) { + @for (episode of item.series; track $index) { } - @if (!item.episodes || item.episodes.length === 0) { - {{ - 'PORTALS.NO_EPISODES' | translate - }} - }
} } - } - } @else { - - @for (item of seasons; track item.id || $index) { - -

{{ item.name }}

-
- @for (episode of $any(item).series; track $index) { - - } -
- } - } -
+
+ +} + diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts index 067dca687..2a9ac29f1 100644 --- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts +++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts @@ -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(() => { 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(); + } } diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html index 0ddad12d4..c8ba7f125 100644 --- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html +++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.html @@ -1,99 +1,76 @@ @let item = selectedItem(); @if (item?.info) { -
+ -
- @if (item.info?.cover) { - - } -
-
-

{{ item.info?.name }}

- @if (item.info?.plot) { - - } + + @if (item.info?.releaseDate) { - + {{ + item.info.releaseDate | slice: 0 : 4 + }} } @if (item.info?.genre) { - + {{ item.info.genre }} + } + @if (item.info?.episode_run_time) { + {{ item.info.episode_run_time }} min/ep } @if (item.info?.rating) { - + + ⭐ {{ item.info.rating }} + } + + + + @if (item.info?.cast) { -
-
{{ 'XTREAM.CAST' | translate }}:
- {{ item.info.cast }} +
+ {{ 'XTREAM.CAST' | translate }} + {{ item.info.cast }}
} @if (item.info?.director) { - +
+ {{ + 'XTREAM.DIRECTOR' | translate + }} + {{ item.info.director }} +
} - @if (item.info?.episode_run_time) { - + + + + + @if (!isFavorite()) { + + } @else { + } -
- @if (!isFavorite()) { - - } @else { - - } -
-
+
+ + -
+
} + diff --git a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts index fe6961ef3..f122a21e2 100644 --- a/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts +++ b/apps/web/src/app/xtream-tauri/serial-details/serial-details.component.ts @@ -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(); + } } diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html index f0ce53f69..5e4d95e5c 100644 --- a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html +++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.html @@ -1,123 +1,104 @@ @let item = selectedItem(); @if (item && item.info) { -
+ -
- @if (item.info?.movie_image) { - Movie poster + + + @if (item.info?.releasedate) { + {{ + item.info.releasedate | slice: 0 : 4 + }} + } + @if (item.info?.genre) { + {{ item.info.genre }} + } + @if (item.info?.duration) { + {{ item.info.duration }} + } + @if (item.info?.rating_imdb) { + + ⭐ {{ item.info.rating_imdb }} + + } + @if (item.info?.country) { + {{ item.info.country }} + } + + + + + @if (item.info?.actors || item.info?.cast) { +
+ {{ 'XTREAM.ACTORS' | translate }} + {{ + item.info.actors || item.info.cast + }} +
+ } + @if (item.info?.director) { +
+ {{ + 'XTREAM.DIRECTOR' | translate + }} + {{ item.info.director }} +
+ } + @if (item.info?.rating_kinopoisk) { +
+ {{ + 'XTREAM.KINOPOISK_RATING' | translate + }} + {{ item.info.rating_kinopoisk }} +
+ } +
+ + + + + @if (!isFavorite()) { + } @else { - Default poster + } -
-
-

{{ item?.info?.name || item?.movie_data?.name }}

- @if (item?.info?.description || item?.info?.plot) { -
- {{ item?.info?.description || item?.info?.plot }} + + + + @if (item.info?.youtube_trailer) { +
+

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

+
+
- } - @if (item?.info?.releasedate) { -
- {{ 'XTREAM.RELEASE_DATE' | translate }}: -
- {{ item?.info?.releasedate }} - } - @if (item?.info?.genre) { -
{{ 'XTREAM.GENRE' | translate }}:
- {{ item?.info?.genre }} - } - @if (item?.info?.country) { -
{{ 'XTREAM.COUNTRY' | translate }}:
- {{ item?.info?.country }} - } - @if (item?.info?.actors || item?.info?.cast) { -
-
{{ 'XTREAM.ACTORS' | translate }}:
- {{ item?.info?.actors || item?.info?.cast }} -
- } - @if (item?.info?.director) { -
{{ 'XTREAM.DIRECTOR' | translate }}:
- {{ item?.info?.director }} - } - @if (item?.info?.duration) { -
{{ 'XTREAM.DURATION' | translate }}:
- {{ item?.info?.duration }} - } - @if (item?.info?.rating_imdb) { -
{{ 'XTREAM.IMDB_RATING' | translate }}:
- {{ item?.info?.rating_imdb }} - } - @if (item?.info?.rating_kinopoisk) { -
- {{ 'XTREAM.KINOPOISK_RATING' | translate }}: -
- {{ item?.info?.rating_kinopoisk }} - } -
-   - @if (!isFavorite()) { - - } @else { - - }
-
-
- @if (item?.info?.youtube_trailer) { -
-

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

- -
- } + } + } + diff --git a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts index 8719fb69a..0e02193bc 100644 --- a/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts +++ b/apps/web/src/app/xtream-tauri/vod-details/vod-details.component.ts @@ -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(); + } } diff --git a/apps/web/src/app/xtream/vod-details/vod-details.component.html b/apps/web/src/app/xtream/vod-details/vod-details.component.html index fe2750368..ca9c900be 100644 --- a/apps/web/src/app/xtream/vod-details/vod-details.component.html +++ b/apps/web/src/app/xtream/vod-details/vod-details.component.html @@ -1,115 +1,97 @@ -
+ -
- @if (item.info?.movie_image) { - Movie poster + + + @if (item.info?.releasedate) { + {{ + item.info.releasedate | slice: 0 : 4 + }} + } + @if (item.info?.genre) { + {{ item.info.genre }} + } + @if (item.info?.duration) { + {{ item.info.duration }} + } + @if (item.info?.rating_imdb) { + + ⭐ {{ item.info.rating_imdb }} + + } + @if (item.info?.country) { + {{ item.info.country }} + } + + + + + @if (item.info?.actors) { +
+ {{ 'XTREAM.ACTORS' | translate }} + {{ item.info.actors }} +
+ } + @if (item.info?.director) { +
+ {{ 'XTREAM.DIRECTOR' | translate }} + {{ item.info.director }} +
+ } + @if (item.info?.rating_kinopoisk) { +
+ {{ + 'XTREAM.KINOPOISK_RATING' | translate + }} + {{ item.info.rating_kinopoisk }} +
+ } +
+ + + + + @if (!isFavorite) { + } @else { -
- } -
-
-

{{ item.info.name }}

- @if (item.info.description) { -
- {{ item.info.description }} -
- } - @if (item.info.releasedate) { -
{{ 'XTREAM.RELEASE_DATE' | translate }}:
- {{ item.info.releasedate }} - } - @if (item.info.genre) { -
{{ 'XTREAM.GENRE' | translate }}:
- {{ item.info.genre }} - } - @if (item.info.country) { -
{{ 'XTREAM.COUNTRY' | translate }}:
- {{ item.info.country }} - } - @if (item.info.actors) { -
-
{{ 'XTREAM.ACTORS' | translate }}:
- {{ item.info.actors }} -
- } - @if (item.info.director) { -
{{ 'XTREAM.DIRECTOR' | translate }}:
- {{ item.info.director }} - } - @if (item.info.duration) { -
{{ 'XTREAM.DURATION' | translate }}:
- {{ item.info.duration }} - } - @if (item.info.rating_imdb) { -
{{ 'XTREAM.IMDB_RATING' | translate }}:
- {{ item.info.rating_imdb }} - } - @if (item.info.rating_kinopoisk) { -
- {{ 'XTREAM.KINOPOISK_RATING' | translate }}: -
- {{ item.info.rating_kinopoisk }} - } -
  - @if (!isFavorite) { - - } @else { - - } + check + {{ 'PORTALS.REMOVE_FROM_FAVORITES' | translate }} + + } + + + + @if (item.info?.youtube_trailer) { +
+

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

+
+ +
-
-
-@if (item.info.youtube_trailer) { -
-

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

- -
-} + } +
+ diff --git a/apps/web/src/app/xtream/vod-details/vod-details.component.ts b/apps/web/src/app/xtream/vod-details/vod-details.component.ts index 21bb0c86e..948406af3 100644 --- a/apps/web/src/app/xtream/vod-details/vod-details.component.ts +++ b/apps/web/src/app/xtream/vod-details/vod-details.component.ts @@ -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(); @Output() removeFromFavoritesClicked = new EventEmitter(); + 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(); + } + } } From 312bc766a69d9c3e905dfa9a9e19df75b267e867 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:56:35 +0100 Subject: [PATCH 07/12] feat(xstream): clear selection and adjust layout --- .../xtream-main-container.component.html | 23 +++++++++---------- .../xtream-main-container.component.scss | 5 ++++ .../xtream-main-container.component.ts | 9 +++++++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html index 7b72c3881..c2298a3c1 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.html +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.html @@ -13,21 +13,20 @@
-
-

- @if (xtreamStore.selectedItem()) { - - } @else { + @if (!xtreamStore.selectedItem()) { +
+

- } - {{ getContentLabel() }} -

-
-
+ {{ getContentLabel() }} +

+
+ } +
diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.scss b/apps/web/src/app/xtream-tauri/xtream-main-container.component.scss index 441671e33..fdda915f4 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.scss +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.scss @@ -49,4 +49,9 @@ .scrollable-content { flex: 1; overflow-y: auto; + + &.no-header { + // When header is hidden, content starts from top + margin-top: 0; + } } diff --git a/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts b/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts index 1306998a7..4f3c30429 100644 --- a/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts +++ b/apps/web/src/app/xtream-tauri/xtream-main-container.component.ts @@ -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'); From 7ae86a82668731e2b70b0d446851fac520a9c94a Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:56:54 +0100 Subject: [PATCH 08/12] feat(ui): unify season components and handle episode info --- .../season-container.component.html | 130 +++-- .../season-container.component.scss | 470 +++++++++++++++++- .../season-container.component.ts | 19 +- .../season-container.component.html | 126 +++-- .../season-container.component.scss | 41 +- .../season-container.component.ts | 23 +- 6 files changed, 673 insertions(+), 136 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.html b/apps/web/src/app/xtream-tauri/season-container/season-container.component.html index 0e319aa82..33b5d2be5 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.html +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.html @@ -1,39 +1,97 @@ -

Seasons and Episodes

-@if (selectedSeason) { - -} -
- @if (!selectedSeason) { - @for (season of seasons() | keyvalue: compareSeasons; track $index) { - - Season {{ season.key }} - +
+
+

Seasons and Episodes

+ @if (selectedSeason) { + } - } @else { - @for (episode of seasons()[selectedSeason]; track $index) { - - @if (episode.info?.movie_image) { - +
+ +
+ @if (!selectedSeason) { + +
+ @for ( + season of seasons() | keyvalue: compareSeasons; + track $index + ) { + } -
- {{ episode.episode_num }}. {{ episode.title }} -
- +
+ } @else { + +
+ @for (episode of seasons()[selectedSeason]; track $index) { + @let episodeInfo = getEpisodeInfo(episode); +
+
+ @if (episodeInfo?.movie_image) { + Episode thumbnail + } @else { +
+ play_circle_outline +
+ } +
+
+ play_arrow +
+
+
+ {{ episode.episode_num }} +
+
+
+

+ {{ episode.episode_num }}. {{ episode.title }} +

+ @if (episodeInfo?.plot) { +

+ {{ episodeInfo.plot }} +

+ } + @if (episodeInfo?.duration) { + + {{ episodeInfo.duration }} + + } +
+
+ } +
} - } -
+
+ diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss b/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss index d21c2f189..87198a36d 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.scss @@ -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; + } + } } diff --git a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts index b8675228f..706e5be32 100644 --- a/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream-tauri/season-container/season-container.component.ts @@ -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>(); @@ -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; + } } diff --git a/apps/web/src/app/xtream/season-container/season-container.component.html b/apps/web/src/app/xtream/season-container/season-container.component.html index 30d3b2898..0d21fc639 100644 --- a/apps/web/src/app/xtream/season-container/season-container.component.html +++ b/apps/web/src/app/xtream/season-container/season-container.component.html @@ -1,33 +1,97 @@ -

Seasons and episodes

-
- @if (!selectedSeason) { - @for (season of seasons | keyvalue; track season) { - - Season {{ season.key }} - - } - } @else { - -
< Back to seasons
-
- @for (episode of seasons[selectedSeason]; track episode) { - - @if (episode.info.movie_image) { - +
+
+

Seasons and Episodes

+ @if (selectedSeason) { + } -
- {{ episode.episode_num }}. {{ episode.title }} -
- - } - } +
-
+
+ @if (!selectedSeason) { + +
+ @for ( + season of seasons | keyvalue: compareSeasons; + track $index + ) { + + } +
+ } @else { + +
+ @for (episode of seasons[selectedSeason]; track $index) { + @let episodeInfo = getEpisodeInfo(episode); +
+
+ @if (episodeInfo?.movie_image) { + Episode thumbnail + } @else { +
+ play_circle_outline +
+ } +
+
+ play_arrow +
+
+
+ {{ episode.episode_num }} +
+
+
+

+ {{ episode.episode_num }}. {{ episode.title }} +

+ @if (episodeInfo?.plot) { +

+ {{ episodeInfo.plot }} +

+ } + @if (episodeInfo?.duration) { + + {{ episodeInfo.duration }} + + } +
+
+ } +
+ } +
+ diff --git a/apps/web/src/app/xtream/season-container/season-container.component.scss b/apps/web/src/app/xtream/season-container/season-container.component.scss index 659474277..cb206faa1 100644 --- a/apps/web/src/app/xtream/season-container/season-container.component.scss +++ b/apps/web/src/app/xtream/season-container/season-container.component.scss @@ -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'; diff --git a/apps/web/src/app/xtream/season-container/season-container.component.ts b/apps/web/src/app/xtream/season-container/season-container.component.ts index 3cea4ab34..e7bf01ea2 100644 --- a/apps/web/src/app/xtream/season-container/season-container.component.ts +++ b/apps/web/src/app/xtream/season-container/season-container.component.ts @@ -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; @@ -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; + } } From 050ea5ae5ea6667bb9461206e7bf0b6d68cbfdc7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:57:07 +0100 Subject: [PATCH 09/12] feat: refine category view image handling --- .../category-content-view.component.ts | 5 +++++ .../category-content-view.component.html | 15 +++++---------- .../category-content-view.component.ts | 2 -- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts index daa800096..fec71cee4 100644 --- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts +++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts @@ -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 ( diff --git a/apps/web/src/app/xtream/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream/category-content-view/category-content-view.component.html index f730b23fa..d045408ef 100644 --- a/apps/web/src/app/xtream/category-content-view/category-content-view.component.html +++ b/apps/web/src/app/xtream/category-content-view/category-content-view.component.html @@ -6,26 +6,21 @@ | sortBy: sortType(); track $index ) { + @let imgSrc = item.stream_icon || item.cover || item.logo; - @if (item.stream_icon || item.cover || item.logo) { + @if (imgSrc) { channel logo - } - @if (!item.stream_icon && !item.cover && !item.logo) { + } @else {
} @if (item.rating || item.rating_imdb) { diff --git a/apps/web/src/app/xtream/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream/category-content-view/category-content-view.component.ts index 5cd19c87a..2f51ab392 100644 --- a/apps/web/src/app/xtream/category-content-view/category-content-view.component.ts +++ b/apps/web/src/app/xtream/category-content-view/category-content-view.component.ts @@ -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, ], From 876699d732e50478980b7f7644cfb636842dbe63 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:57:19 +0100 Subject: [PATCH 10/12] chore: remove debug log from stalker store --- apps/web/src/app/stalker/stalker.store.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/app/stalker/stalker.store.ts b/apps/web/src/app/stalker/stalker.store.ts index 2a55c8599..0c882fee7 100644 --- a/apps/web/src/app/stalker/stalker.store.ts +++ b/apps/web/src/app/stalker/stalker.store.ts @@ -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; From fb14abbd86ca34a3da738c0a487367ea0264ab10 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:57:34 +0100 Subject: [PATCH 11/12] refactor(ui): adjust header flow for selected item --- .../stalker-main-container.component.html | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/apps/web/src/app/stalker/stalker-main-container.component.html b/apps/web/src/app/stalker/stalker-main-container.component.html index 86ef1868f..9b8491c4c 100644 --- a/apps/web/src/app/stalker/stalker-main-container.component.html +++ b/apps/web/src/app/stalker/stalker-main-container.component.html @@ -88,31 +88,32 @@
-
-

- @if (stalkerStore.selectedItem()) { - - } @else if (!isItvCategorySelected) { - - } + @if (!stalkerStore.selectedItem()) { +
+

+ @if (!isItvCategorySelected) { + + } - @if (!isItvCategorySelected) { - {{ selectedCategoryTitle() }} - } + @if (!isItvCategorySelected) { + {{ selectedCategoryTitle() }} + } - @if (!contentItems() && !isContentLoading()) { -

- {{ 'PORTALS.SELECT_CATEGORY' | translate }} -

- } -

-
+ @if (!contentItems() && !isContentLoading()) { +

+ {{ 'PORTALS.SELECT_CATEGORY' | translate }} +

+ } + +
+ } -
+
From 8a1e1dee7d0c437bed5a1bad6b632eaca655d95f Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 5 Jan 2026 21:58:12 +0100 Subject: [PATCH 12/12] chore: remove whitespaces in coderabbit.yaml --- .coderabbit.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 33ded29fa..b1c350a52 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -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. - ​