modernise the discover

This commit is contained in:
tidusjar 2025-08-23 21:31:26 +01:00
commit 9524608c1f
4 changed files with 157 additions and 126 deletions

View file

@ -1,21 +1,25 @@
<div class="right" *ngIf="discoverType !== DiscoverType.Seasonal"> @if (discoverType() !== DiscoverType.Seasonal) {
<mat-button-toggle-group name="discoverMode" (change)="toggleChanged($event)" value="{{discoverOptions}}" class="discover-filter-buttons-group"> <div class="right">
<mat-button-toggle id="{{id}}Combined" [ngClass]="{'button-active': discoverOptions === DiscoverOption.Combined}" value="{{DiscoverOption.Combined}}" class="discover-filter-button">{{'Discovery.Combined' | translate}}</mat-button-toggle> <mat-button-toggle-group name="discoverMode" (change)="toggleChanged($event)" [value]="discoverOptions()" class="discover-filter-buttons-group">
<mat-button-toggle id="{{id}}Movie" [ngClass]="{'button-active': discoverOptions === DiscoverOption.Movie}" value="{{DiscoverOption.Movie}}" class="discover-filter-button">{{'Discovery.Movies' | translate}}</mat-button-toggle> <mat-button-toggle [id]="id() + 'Combined'" [class.button-active]="discoverOptions() === DiscoverOption.Combined" [value]="DiscoverOption.Combined" class="discover-filter-button">{{'Discovery.Combined' | translate}}</mat-button-toggle>
<mat-button-toggle id="{{id}}Tv" [ngClass]="{'button-active': discoverOptions === DiscoverOption.Tv}" value="{{DiscoverOption.Tv}}" class="discover-filter-button">{{'Discovery.Tv' | translate}}</mat-button-toggle> <mat-button-toggle [id]="id() + 'Movie'" [class.button-active]="discoverOptions() === DiscoverOption.Movie" [value]="DiscoverOption.Movie" class="discover-filter-button">{{'Discovery.Movies' | translate}}</mat-button-toggle>
</mat-button-toggle-group> <mat-button-toggle [id]="id() + 'Tv'" [class.button-active]="discoverOptions() === DiscoverOption.Tv" [value]="DiscoverOption.Tv" class="discover-filter-button">{{'Discovery.Tv' | translate}}</mat-button-toggle>
</div> </mat-button-toggle-group>
@defer (when discoverResults.length > 0; prefetch on idle) { </div>
<p-carousel #carousel [numVisible]="10" [numScroll]="10" [page]="0" [value]="discoverResults" [responsiveOptions]="responsiveOptions" (onPage)="newPage()"> }
<ng-template let-result pTemplate="item">
<discover-card [discoverType]="discoverType" [isAdmin]="isAdmin" [result]="result" [is4kEnabled]="is4kEnabled"></discover-card> @defer (when hasResults(); prefetch on idle) {
</ng-template> <p-carousel #carousel [numVisible]="10" [numScroll]="10" [page]="0" [value]="discoverResults()" [responsiveOptions]="responsiveOptions" (onPage)="newPage()">
</p-carousel> <ng-template let-result pTemplate="item">
} <discover-card [discoverType]="discoverType()" [isAdmin]="isAdmin()" [result]="result" [is4kEnabled]="is4kEnabled()"></discover-card>
@placeholder(minimum 300) { </ng-template>
<div class="row loading-container"> </p-carousel>
<div class="col-2" *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]"> } @placeholder(minimum 300) {
<p-skeleton width="100%" height="270px"></p-skeleton> <div class="row loading-container">
</div> @for (item of [1,2,3,4,5,6,7,8,9,10]; track item) {
<div class="col-2">
<p-skeleton width="100%" height="270px"></p-skeleton>
</div>
}
</div> </div>
} }

View file

@ -1,4 +1,4 @@
import { Component, OnInit, Input, ViewChild, Output, EventEmitter, Inject } from "@angular/core"; import { Component, ViewChild, Inject, input, output, signal, computed, inject, ChangeDetectionStrategy } from "@angular/core";
import { DiscoverOption, IDiscoverCardResult } from "../../interfaces"; import { DiscoverOption, IDiscoverCardResult } from "../../interfaces";
import { ISearchMovieResult, ISearchTvResult, RequestType } from "../../../interfaces"; import { ISearchMovieResult, ISearchTvResult, RequestType } from "../../../interfaces";
import { SearchV2Service } from "../../../services"; import { SearchV2Service } from "../../../services";
@ -17,46 +17,55 @@ export enum DiscoverType {
} }
@Component({ @Component({
standalone: false, standalone: false,
selector: "carousel-list", selector: "carousel-list",
templateUrl: "./carousel-list.component.html", templateUrl: "./carousel-list.component.html",
styleUrls: ["./carousel-list.component.scss"], styleUrls: ["./carousel-list.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CarouselListComponent implements OnInit { export class CarouselListComponent {
// Inputs using new input() function
public discoverType = input.required<DiscoverType>();
public id = input.required<string>();
public isAdmin = input<boolean>(false);
// Output using new output() function
public movieCount = output<number>();
@Input() public discoverType: DiscoverType;
@Input() public id: string;
@Input() public isAdmin: boolean;
@Output() public movieCount: EventEmitter<number> = new EventEmitter();
@ViewChild('carousel', {static: false}) carousel: Carousel; @ViewChild('carousel', {static: false}) carousel: Carousel;
// Services using inject() function
private searchService = inject(SearchV2Service);
private storageService = inject(StorageService);
private featureFacade = inject(FeaturesFacade);
private baseUrl = inject(APP_BASE_HREF);
// Public constants
public DiscoverOption = DiscoverOption; public DiscoverOption = DiscoverOption;
public discoverOptions: DiscoverOption = DiscoverOption.Combined;
public discoverResults: IDiscoverCardResult[] = [];
public movies: ISearchMovieResult[] = [];
public tvShows: ISearchTvResult[] = [];
public responsiveOptions: any;
public RequestType = RequestType; public RequestType = RequestType;
public loadingFlag: boolean;
public DiscoverType = DiscoverType; public DiscoverType = DiscoverType;
public is4kEnabled = false;
// State using signals
public discoverOptions = signal<DiscoverOption>(DiscoverOption.Combined);
public discoverResults = signal<IDiscoverCardResult[]>([]);
public movies = signal<ISearchMovieResult[]>([]);
public tvShows = signal<ISearchTvResult[]>([]);
public loadingFlag = signal<boolean>(false);
public is4kEnabled = signal<boolean>(false);
// Computed properties
public hasResults = computed(() => this.discoverResults().length > 0);
public totalResults = computed(() => this.discoverResults().length);
get mediaTypeStorageKey() { get mediaTypeStorageKey() {
return "DiscoverOptions" + this.discoverType.toString(); return "DiscoverOptions" + this.discoverType().toString();
}; };
private amountToLoad = 10; private amountToLoad = 10;
private currentlyLoaded = 0; private currentlyLoaded = 0;
private baseUrl: string = ""; public responsiveOptions: any;
constructor() {
constructor(private searchService: SearchV2Service,
private storageService: StorageService,
private featureFacade: FeaturesFacade,
@Inject(APP_BASE_HREF) private href: string) {
if (this.href.length > 1) {
this.baseUrl = this.href;
}
Carousel.prototype.onTouchMove = () => { }, Carousel.prototype.onTouchMove = () => { },
this.responsiveOptions = [ this.responsiveOptions = [
@ -149,12 +158,14 @@ export class CarouselListComponent implements OnInit {
} }
public async ngOnInit() { public async ngOnInit() {
// Initialize 4K feature flag
this.is4kEnabled = this.featureFacade.is4kEnabled(); this.is4kEnabled.set(this.featureFacade.is4kEnabled());
this.currentlyLoaded = 0; this.currentlyLoaded = 0;
// Load saved discover options from storage
const localDiscoverOptions = +this.storageService.get(this.mediaTypeStorageKey); const localDiscoverOptions = +this.storageService.get(this.mediaTypeStorageKey);
if (localDiscoverOptions) { if (localDiscoverOptions) {
this.discoverOptions = DiscoverOption[DiscoverOption[localDiscoverOptions]]; this.discoverOptions.set(DiscoverOption[DiscoverOption[localDiscoverOptions]]);
} }
// Load initial data - just enough to fill the first carousel page // Load initial data - just enough to fill the first carousel page
@ -162,10 +173,9 @@ export class CarouselListComponent implements OnInit {
await this.loadData(false); await this.loadData(false);
// If we don't have enough results to fill the carousel, load one more batch // If we don't have enough results to fill the carousel, load one more batch
if (this.discoverResults.length < 10) { if (this.discoverResults().length < 10) {
await this.loadData(false); await this.loadData(false);
} }
} }
public async toggleChanged(event: MatButtonToggleChange) { public async toggleChanged(event: MatButtonToggleChange) {
@ -183,7 +193,7 @@ export class CarouselListComponent implements OnInit {
if (end) { if (end) {
var moviePromise: Promise<void>; var moviePromise: Promise<void>;
var tvPromise: Promise<void>; var tvPromise: Promise<void>;
switch (+this.discoverOptions) { switch (+this.discoverOptions()) {
case DiscoverOption.Combined: case DiscoverOption.Combined:
moviePromise = this.loadMovies(); moviePromise = this.loadMovies();
tvPromise = this.loadTv(); tvPromise = this.loadTv();
@ -205,7 +215,7 @@ export class CarouselListComponent implements OnInit {
private async loadData(clearExisting: boolean = true) { private async loadData(clearExisting: boolean = true) {
var moviePromise: Promise<void>; var moviePromise: Promise<void>;
var tvPromise: Promise<void>; var tvPromise: Promise<void>;
switch (+this.discoverOptions) { switch (+this.discoverOptions()) {
case DiscoverOption.Combined: case DiscoverOption.Combined:
moviePromise = this.loadMovies(); moviePromise = this.loadMovies();
tvPromise = this.loadTv(); tvPromise = this.loadTv();
@ -223,50 +233,50 @@ export class CarouselListComponent implements OnInit {
} }
private async switchDiscoverMode(newMode: DiscoverOption) { private async switchDiscoverMode(newMode: DiscoverOption) {
if (this.discoverOptions === newMode) { if (this.discoverOptions() === newMode) {
return; return;
} }
this.loading(); this.loading();
this.currentlyLoaded = 0; this.currentlyLoaded = 0;
this.discoverOptions = +newMode; this.discoverOptions.set(+newMode);
this.storageService.save(this.mediaTypeStorageKey, newMode.toString()); this.storageService.save(this.mediaTypeStorageKey, newMode.toString());
await this.loadData(); await this.loadData();
this.finishLoading(); this.finishLoading();
} }
private async loadMovies() { private async loadMovies() {
switch (this.discoverType) { switch (this.discoverType()) {
case DiscoverType.Popular: case DiscoverType.Popular:
this.movies = await this.searchService.popularMoviesByPage(this.currentlyLoaded, this.amountToLoad); this.movies.set(await this.searchService.popularMoviesByPage(this.currentlyLoaded, this.amountToLoad));
break; break;
case DiscoverType.Trending: case DiscoverType.Trending:
this.movies = await this.searchService.nowPlayingMoviesByPage(this.currentlyLoaded, this.amountToLoad); this.movies.set(await this.searchService.nowPlayingMoviesByPage(this.currentlyLoaded, this.amountToLoad));
break; break;
case DiscoverType.Upcoming: case DiscoverType.Upcoming:
this.movies = await this.searchService.upcomingMoviesByPage(this.currentlyLoaded, this.amountToLoad); this.movies.set(await this.searchService.upcomingMoviesByPage(this.currentlyLoaded, this.amountToLoad));
break break;
case DiscoverType.RecentlyRequested: case DiscoverType.RecentlyRequested:
this.movies = await this.searchService.recentlyRequestedMoviesByPage(this.currentlyLoaded, this.amountToLoad); this.movies.set(await this.searchService.recentlyRequestedMoviesByPage(this.currentlyLoaded, this.amountToLoad));
break; break;
case DiscoverType.Seasonal: case DiscoverType.Seasonal:
this.movies = await this.searchService.seasonalMoviesByPage(this.currentlyLoaded, this.amountToLoad); this.movies.set(await this.searchService.seasonalMoviesByPage(this.currentlyLoaded, this.amountToLoad));
break; break;
} }
this.movieCount.emit(this.movies.length); this.movieCount.emit(this.movies().length);
this.currentlyLoaded += this.amountToLoad; this.currentlyLoaded += this.amountToLoad;
} }
private async loadTv() { private async loadTv() {
switch (this.discoverType) { switch (this.discoverType()) {
case DiscoverType.Popular: case DiscoverType.Popular:
this.tvShows = await this.searchService.popularTvByPage(this.currentlyLoaded, this.amountToLoad); this.tvShows.set(await this.searchService.popularTvByPage(this.currentlyLoaded, this.amountToLoad));
break; break;
case DiscoverType.Trending: case DiscoverType.Trending:
this.tvShows = await this.searchService.trendingTvByPage(this.currentlyLoaded, this.amountToLoad); this.tvShows.set(await this.searchService.trendingTvByPage(this.currentlyLoaded, this.amountToLoad));
break; break;
case DiscoverType.Upcoming: case DiscoverType.Upcoming:
this.tvShows = await this.searchService.anticipatedTvByPage(this.currentlyLoaded, this.amountToLoad); this.tvShows.set(await this.searchService.anticipatedTvByPage(this.currentlyLoaded, this.amountToLoad));
break break;
case DiscoverType.RecentlyRequested: case DiscoverType.RecentlyRequested:
// this.tvShows = await this.searchService.recentlyRequestedMoviesByPage(this.currentlyLoaded, this.amountToLoad); // TODO need to do some more mapping // this.tvShows = await this.searchService.recentlyRequestedMoviesByPage(this.currentlyLoaded, this.amountToLoad); // TODO need to do some more mapping
break; break;
@ -284,7 +294,7 @@ export class CarouselListComponent implements OnInit {
private createModel() { private createModel() {
const tempResults = <IDiscoverCardResult[]>[]; const tempResults = <IDiscoverCardResult[]>[];
switch (+this.discoverOptions) { switch (+this.discoverOptions()) {
case DiscoverOption.Combined: case DiscoverOption.Combined:
tempResults.push(...this.mapMovieModel()); tempResults.push(...this.mapMovieModel());
tempResults.push(...this.mapTvModel()); tempResults.push(...this.mapTvModel());
@ -298,14 +308,14 @@ export class CarouselListComponent implements OnInit {
break; break;
} }
this.discoverResults.push(...tempResults); this.discoverResults.update(current => [...current, ...tempResults]);
this.finishLoading(); this.finishLoading();
} }
private mapMovieModel(): IDiscoverCardResult[] { private mapMovieModel(): IDiscoverCardResult[] {
const tempResults = <IDiscoverCardResult[]>[]; const tempResults = <IDiscoverCardResult[]>[];
this.movies.forEach(m => { this.movies().forEach(m => {
tempResults.push({ tempResults.push({
available: m.available, available: m.available,
posterPath: m.posterPath ? `https://image.tmdb.org/t/p/w500/${m.posterPath}` : this.baseUrl + "/images/default_movie_poster.png", posterPath: m.posterPath ? `https://image.tmdb.org/t/p/w500/${m.posterPath}` : this.baseUrl + "/images/default_movie_poster.png",
@ -327,7 +337,7 @@ export class CarouselListComponent implements OnInit {
private mapTvModel(): IDiscoverCardResult[] { private mapTvModel(): IDiscoverCardResult[] {
const tempResults = <IDiscoverCardResult[]>[]; const tempResults = <IDiscoverCardResult[]>[];
this.tvShows.forEach(m => { this.tvShows().forEach(m => {
tempResults.push({ tempResults.push({
available: m.fullyAvailable, available: m.fullyAvailable,
posterPath: m.backdropPath ? `https://image.tmdb.org/t/p/w500/${m.backdropPath}` : this.baseUrl + "/images/default_tv_poster.png", posterPath: m.backdropPath ? `https://image.tmdb.org/t/p/w500/${m.backdropPath}` : this.baseUrl + "/images/default_tv_poster.png",
@ -348,7 +358,7 @@ export class CarouselListComponent implements OnInit {
} }
private clear() { private clear() {
this.discoverResults = []; this.discoverResults.set([]);
} }
private shuffle(discover: IDiscoverCardResult[]): IDiscoverCardResult[] { private shuffle(discover: IDiscoverCardResult[]): IDiscoverCardResult[] {
@ -360,11 +370,11 @@ export class CarouselListComponent implements OnInit {
} }
private loading() { private loading() {
this.loadingFlag = true; this.loadingFlag.set(true);
} }
private finishLoading() { private finishLoading() {
this.loadingFlag = false; this.loadingFlag.set(false);
} }

View file

@ -22,50 +22,58 @@
<div class="section"> <div class="section">
<h2>{{ 'Discovery.RecentlyRequestedTab' | translate }}</h2> <h2>{{ 'Discovery.RecentlyRequestedTab' | translate }}</h2>
<div class="row loading-container"> <div class="row loading-container">
<div class="col-2" *ngFor="let item of [1,2,3,4,5]"> @for (item of [1,2,3,4,5]; track item) {
<p-skeleton width="100%" height="270px"></p-skeleton> <div class="col-2">
</div> <p-skeleton width="100%" height="270px"></p-skeleton>
</div>
}
</div> </div>
</div> </div>
} }
@defer (on viewport; prefetch on idle) { @if (showSeasonal()) {
<div class="section" [hidden]="!showSeasonal"> @defer (on viewport; prefetch on idle) {
<h2>{{ 'Discovery.SeasonalTab' | translate }}</h2> <div class="section">
<div> <h2>{{ 'Discovery.SeasonalTab' | translate }}</h2>
<carousel-list <div>
[id]="'seasonal'" <carousel-list
[isAdmin]="isAdmin" [id]="'seasonal'"
[discoverType]="DiscoverType.Seasonal" [isAdmin]="isAdmin()"
(movieCount)="setSeasonalMovieCount($event)" [discoverType]="DiscoverType.Seasonal"
></carousel-list> (movieCount)="setSeasonalMovieCount($event)"
</div> ></carousel-list>
</div>
} @placeholder(minimum 300) {
<div class="section">
<h2>{{ 'Discovery.SeasonalTab' | translate }}</h2>
<div class="row loading-container">
<div class="col-2" *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]">
<p-skeleton width="100%" height="270px"></p-skeleton>
</div> </div>
</div> </div>
</div> } @placeholder(minimum 300) {
<div class="section">
<h2>{{ 'Discovery.SeasonalTab' | translate }}</h2>
<div class="row loading-container">
@for (item of [1,2,3,4,5,6,7,8,9,10]; track item) {
<div class="col-2">
<p-skeleton width="100%" height="270px"></p-skeleton>
</div>
}
</div>
</div>
}
} }
@defer (on viewport; prefetch on idle) { @defer (on viewport; prefetch on idle) {
<div class="section"> <div class="section">
<h2>{{ 'Discovery.PopularTab' | translate }}</h2> <h2>{{ 'Discovery.PopularTab' | translate }}</h2>
<div> <div>
<carousel-list [id]="'popular'" [isAdmin]="isAdmin" [discoverType]="DiscoverType.Popular"></carousel-list> <carousel-list [id]="'popular'" [isAdmin]="isAdmin()" [discoverType]="DiscoverType.Popular"></carousel-list>
</div> </div>
</div> </div>
} @placeholder(minimum 300) { } @placeholder(minimum 300) {
<div class="section"> <div class="section">
<h2>{{ 'Discovery.PopularTab' | translate }}</h2> <h2>{{ 'Discovery.PopularTab' | translate }}</h2>
<div class="row loading-container"> <div class="row loading-container">
<div class="col-2" *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]"> @for (item of [1,2,3,4,5,6,7,8,9,10]; track item) {
<p-skeleton width="100%" height="270px"></p-skeleton> <div class="col-2">
</div> <p-skeleton width="100%" height="270px"></p-skeleton>
</div>
}
</div> </div>
</div> </div>
} }
@ -73,17 +81,19 @@
@defer (on viewport; prefetch on idle) { @defer (on viewport; prefetch on idle) {
<div class="section"> <div class="section">
<h2>{{ 'Discovery.TrendingTab' | translate }}</h2> <h2>{{ 'Discovery.TrendingTab' | translate }}</h2>
<div> <div>
<carousel-list [id]="'trending'" [isAdmin]="isAdmin" [discoverType]="DiscoverType.Trending"></carousel-list> <carousel-list [id]="'trending'" [isAdmin]="isAdmin()" [discoverType]="DiscoverType.Trending"></carousel-list>
</div> </div>
</div> </div>
} @placeholder(minimum 300) { } @placeholder(minimum 300) {
<div class="section"> <div class="section">
<h2>{{ 'Discovery.TrendingTab' | translate }}</h2> <h2>{{ 'Discovery.TrendingTab' | translate }}</h2>
<div class="row loading-container"> <div class="row loading-container">
<div class="col-2" *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]"> @for (item of [1,2,3,4,5,6,7,8,9,10]; track item) {
<p-skeleton width="100%" height="270px"></p-skeleton> <div class="col-2">
</div> <p-skeleton width="100%" height="270px"></p-skeleton>
</div>
}
</div> </div>
</div> </div>
} }
@ -91,17 +101,19 @@
@defer (on viewport; prefetch on idle) { @defer (on viewport; prefetch on idle) {
<div class="section"> <div class="section">
<h2>{{ 'Discovery.UpcomingTab' | translate }}</h2> <h2>{{ 'Discovery.UpcomingTab' | translate }}</h2>
<div> <div>
<carousel-list [id]="'upcoming'" [isAdmin]="isAdmin" [discoverType]="DiscoverType.Upcoming"></carousel-list> <carousel-list [id]="'upcoming'" [isAdmin]="isAdmin()" [discoverType]="DiscoverType.Upcoming"></carousel-list>
</div> </div>
</div> </div>
} @placeholder(minimum 300) { } @placeholder(minimum 300) {
<div class="section"> <div class="section">
<h2>{{ 'Discovery.UpcomingTab' | translate }}</h2> <h2>{{ 'Discovery.UpcomingTab' | translate }}</h2>
<div class="row loading-container"> <div class="row loading-container">
<div class="col-2" *ngFor="let item of [1,2,3,4,5,6,7,8,9,10]"> @for (item of [1,2,3,4,5,6,7,8,9,10]; track item) {
<p-skeleton width="100%" height="270px"></p-skeleton> <div class="col-2">
</div> <p-skeleton width="100%" height="270px"></p-skeleton>
</div>
}
</div> </div>
</div> </div>
} }

View file

@ -1,29 +1,34 @@
import { Component, OnInit } from "@angular/core"; import { Component, computed, inject, signal, ChangeDetectionStrategy } from "@angular/core";
import { AuthService } from "../../../auth/auth.service"; import { AuthService } from "../../../auth/auth.service";
import { DiscoverType } from "../carousel-list/carousel-list.component"; import { DiscoverType } from "../carousel-list/carousel-list.component";
@Component({ @Component({
standalone: false, standalone: false,
templateUrl: "./discover.component.html", templateUrl: "./discover.component.html",
styleUrls: ["./discover.component.scss"], styleUrls: ["./discover.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class DiscoverComponent implements OnInit { export class DiscoverComponent {
// Services using inject() function
private authService = inject(AuthService);
// Public constants
public DiscoverType = DiscoverType; public DiscoverType = DiscoverType;
public isAdmin: boolean;
public showSeasonal: boolean;
constructor(private authService: AuthService) { } // State using signals
public isAdmin = signal<boolean>(false);
public seasonalMovieCount = signal<number>(0);
public ngOnInit(): void { // Computed properties
this.isAdmin = this.authService.isAdmin(); public showSeasonal = computed(() => this.seasonalMovieCount() > 0);
constructor() {
// Initialize admin status
this.isAdmin.set(this.authService.isAdmin());
} }
public setSeasonalMovieCount(count: number) { public setSeasonalMovieCount(count: number): void {
if (count > 0) { this.seasonalMovieCount.set(count);
this.showSeasonal = true;
}
} }
} }