chore: update TypeScript build info cache
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
11
angular.json
@ -12,15 +12,16 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": {
|
"outputPath": "./dist",
|
||||||
"base": "./dist",
|
"baseHref": "/",
|
||||||
"browser": "."
|
|
||||||
},
|
|
||||||
"browser": "index.tsx",
|
"browser": "index.tsx",
|
||||||
"tsConfig": "tsconfig.json",
|
"tsConfig": "tsconfig.json",
|
||||||
"assets": [
|
"assets": [
|
||||||
"assets",
|
"assets",
|
||||||
"index.css"
|
"public",
|
||||||
|
"index.css",
|
||||||
|
"index.html",
|
||||||
|
{ "glob": "**/*", "input": "public/", "output": "/" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
BIN
db/newtube.db
11
index.html
@ -2,9 +2,15 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>NewTube</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/png" href="src/assets/images/NewTube.png">
|
<title>NewTube</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="NewTube" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
try {
|
try {
|
||||||
var t = localStorage.getItem('newtube.theme') || 'system';
|
var t = localStorage.getItem('newtube.theme') || 'system';
|
||||||
@ -53,5 +59,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-900 text-slate-200 antialiased">
|
<body class="bg-slate-900 text-slate-200 antialiased">
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
<script type="module" src="/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
BIN
public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
public/favicon-96x96.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
3
public/favicon.svg
Normal file
After Width: | Height: | Size: 1003 KiB |
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
21
public/site.webmanifest
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "NewTube",
|
||||||
|
"short_name": "NewTube",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
BIN
public/web-app-manifest-192x192.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
After Width: | Height: | Size: 178 KiB |
@ -9,7 +9,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<a routerLink="/" class="flex items-center space-x-2 shrink-0">
|
<a routerLink="/" class="flex items-center space-x-2 shrink-0">
|
||||||
<img src="assets/images/NewTube.png" alt="" class="h-8 w-auto" />
|
<img src="images/NewTube.png" alt="" class="h-8 w-auto" />
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-white">NewTube</h1>
|
<h1 class="text-2xl font-bold tracking-tight text-white">NewTube</h1>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -56,9 +56,75 @@
|
|||||||
|
|
||||||
<!-- Sections Tabs mimic (simple headings) -->
|
<!-- Sections Tabs mimic (simple headings) -->
|
||||||
<div class="space-y-10">
|
<div class="space-y-10">
|
||||||
|
|
||||||
|
<!-- Videos -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl font-semibold mb-3">{{ 'themes.videos' | t }}</h2>
|
||||||
|
<div *ngIf="videos.loading && !nonShorts.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
|
||||||
|
<div class="w-full h-32 bg-slate-800"></div>
|
||||||
|
<div class="p-2">
|
||||||
|
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
|
||||||
|
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="!videos.loading && nonShorts.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||||
|
<div *ngIf="videos.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
|
||||||
|
{{ videos.error }}
|
||||||
|
<button class="ml-2 underline hover:text-red-100" (click)="retry(videos)">{{ 'action.retry' | t }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="nonShorts.length">
|
||||||
|
<a *ngFor="let v of (nonShorts | slice:0:20); trackBy: trackByVideo"
|
||||||
|
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
|
||||||
|
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
|
||||||
|
<div class="relative">
|
||||||
|
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
|
||||||
|
<div class="absolute bottom-2 left-2">
|
||||||
|
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3" *ngIf="nonShorts.length > 20">
|
||||||
|
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200"
|
||||||
|
(click)="showMore(videos)"
|
||||||
|
aria-label="Afficher plus de vidéos">
|
||||||
|
{{ 'loading.more' | t }} ({{nonShorts.length - 20}})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Shorts: show only if any are detected -->
|
||||||
|
<section *ngIf="shorts.length > 0">
|
||||||
|
<h2 class="text-xl font-semibold mb-3">Shorts</h2>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<a *ngFor="let v of (shorts | slice:0:shortsVisibleCount); trackBy: trackByVideo"
|
||||||
|
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
|
||||||
|
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
|
||||||
|
<div class="relative">
|
||||||
|
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-64 object-cover">
|
||||||
|
<div class="absolute bottom-2 left-2">
|
||||||
|
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Bouton Voir plus pour les Shorts -->
|
||||||
|
<div class="mt-3" *ngIf="shorts.length > shortsVisibleCount">
|
||||||
|
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200"
|
||||||
|
(click)="showMoreShorts()"
|
||||||
|
aria-label="Afficher plus de shorts">
|
||||||
|
{{ 'loading.more' | t }} ({{shorts.length - shortsVisibleCount}})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Recorded Streams -->
|
<!-- Recorded Streams -->
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-xl font-semibold mb-3">{{ 'themes.recorded' | t }}</h3>
|
<h2 class="text-xl font-semibold mb-3">{{ 'themes.recorded' | t }}</h2>
|
||||||
<div *ngIf="recorded.loading && !recorded.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<div *ngIf="recorded.loading && !recorded.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
|
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
|
||||||
<div class="w-full h-32 bg-slate-800"></div>
|
<div class="w-full h-32 bg-slate-800"></div>
|
||||||
@ -68,13 +134,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!recorded.loading && recorded.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
<div *ngIf="!recorded.loading && recordedNonShorts.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
||||||
<div *ngIf="recorded.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
|
<div *ngIf="recorded.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
|
||||||
{{ recorded.error }}
|
{{ recorded.error }}
|
||||||
<button class="ml-2 underline hover:text-red-100" (click)="retry(recorded)">{{ 'action.retry' | t }}</button>
|
<button class="ml-2 underline hover:text-red-100" (click)="retry(recorded)">{{ 'action.retry' | t }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="recorded.items.length">
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="recordedNonShorts.length">
|
||||||
<a *ngFor="let v of (recorded.items | slice:0:recorded.visibleCount); trackBy: trackByVideo"
|
<a *ngFor="let v of (recordedNonShorts | slice:0:20); trackBy: trackByVideo"
|
||||||
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
|
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
|
||||||
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
|
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@ -86,81 +152,21 @@
|
|||||||
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- Infinite scroll anchor -->
|
<div class="mt-3" *ngIf="recordedNonShorts.length > 20">
|
||||||
<app-infinite-anchor
|
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200"
|
||||||
*ngIf="recorded.items.length && (recorded.items.length >= recorded.visibleCount)"
|
(click)="showMore(recorded)"
|
||||||
[busy]="recorded.loading"
|
aria-label="Afficher plus de vidéos enregistrées">
|
||||||
[disabled]="!recorded.nextCursor && recorded.items.length <= recorded.visibleCount"
|
{{ 'loading.more' | t }} ({{recordedNonShorts.length - 20}})
|
||||||
[rootMargin]="'1000px 0px 1000px 0px'"
|
|
||||||
(loadMore)="showMore(recorded)">
|
|
||||||
</app-infinite-anchor>
|
|
||||||
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="recorded.loading">
|
|
||||||
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
|
|
||||||
{{ 'loading.more' | t }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-3" *ngIf="recorded.items.length && (recorded.items.length > recorded.visibleCount || recorded.nextCursor)">
|
|
||||||
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
|
|
||||||
[disabled]="recorded.loading"
|
|
||||||
(click)="showMore(recorded)">
|
|
||||||
{{ 'loading.more' | t }} (20)
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Videos -->
|
|
||||||
<section>
|
|
||||||
<h3 class="text-xl font-semibold mb-3">{{ 'themes.videos' | t }}</h3>
|
|
||||||
<div *ngIf="videos.loading && !videos.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
|
||||||
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
|
|
||||||
<div class="w-full h-32 bg-slate-800"></div>
|
|
||||||
<div class="p-2">
|
|
||||||
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
|
|
||||||
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div *ngIf="!videos.loading && videos.items.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
|
||||||
<div *ngIf="videos.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
|
|
||||||
{{ videos.error }}
|
|
||||||
<button class="ml-2 underline hover:text-red-100" (click)="retry(videos)">{{ 'action.retry' | t }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="videos.items.length">
|
|
||||||
<a *ngFor="let v of (videos.items | slice:0:videos.visibleCount); trackBy: trackByVideo"
|
|
||||||
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
|
|
||||||
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
|
|
||||||
<div class="relative">
|
|
||||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
|
|
||||||
<div class="absolute bottom-2 left-2">
|
|
||||||
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<!-- Infinite scroll anchor -->
|
|
||||||
<app-infinite-anchor
|
|
||||||
*ngIf="videos.items.length && (videos.items.length >= videos.visibleCount)"
|
|
||||||
[busy]="videos.loading"
|
|
||||||
[disabled]="!videos.nextCursor && videos.items.length <= videos.visibleCount"
|
|
||||||
[rootMargin]="'1000px 0px 1000px 0px'"
|
|
||||||
(loadMore)="showMore(videos)">
|
|
||||||
</app-infinite-anchor>
|
|
||||||
<div class="mt-2 flex items-center justify-center text-slate-400 text-sm" *ngIf="videos.loading">
|
|
||||||
<span class="inline-block h-4 w-4 border-2 border-slate-500 border-t-transparent rounded-full animate-spin mr-2"></span>
|
|
||||||
{{ 'loading.more' | t }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-3" *ngIf="videos.items.length && (videos.items.length > videos.visibleCount || videos.nextCursor)">
|
|
||||||
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200 disabled:opacity-60"
|
|
||||||
[disabled]="videos.loading"
|
|
||||||
(click)="showMore(videos)">
|
|
||||||
{{ 'loading.more' | t }} (20)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Categories (static recommended) -->
|
<!-- Categories (static recommended) -->
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-xl font-semibold mb-3">{{ 'themes.categories' | t }}</h3>
|
<h2 class="text-xl font-semibold mb-3">{{ 'themes.categories' | t }}</h2>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
<div *ngFor="let c of categories()" class="bg-slate-800/60 rounded-xl p-4 border border-slate-700">
|
<div *ngFor="let c of categories()" class="bg-slate-800/60 rounded-xl p-4 border border-slate-700">
|
||||||
<div class="text-4xl">{{ c.emoji }}</div>
|
<div class="text-4xl">{{ c.emoji }}</div>
|
||||||
|
@ -6,7 +6,6 @@ import { InstanceService, Provider } from '../../services/instance.service';
|
|||||||
import { ThemesService } from '../../services/themes.service';
|
import { ThemesService } from '../../services/themes.service';
|
||||||
import { YoutubeApiService } from '../../services/youtube-api.service';
|
import { YoutubeApiService } from '../../services/youtube-api.service';
|
||||||
import { Video } from '../../models/video.model';
|
import { Video } from '../../models/video.model';
|
||||||
import { InfiniteAnchorComponent } from '../shared/infinite-anchor/infinite-anchor.component';
|
|
||||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||||
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
|
||||||
|
|
||||||
@ -25,7 +24,7 @@ interface SectionState {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
templateUrl: './provider-theme-page.component.html',
|
templateUrl: './provider-theme-page.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [CommonModule, FormsModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent]
|
imports: [CommonModule, FormsModule, RouterLink, TranslatePipe, LikeButtonComponent]
|
||||||
})
|
})
|
||||||
export class ProviderThemePageComponent implements OnDestroy {
|
export class ProviderThemePageComponent implements OnDestroy {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
@ -53,7 +52,12 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
|
|
||||||
// Sections (Live removed on provider pages)
|
// Sections (Live removed on provider pages)
|
||||||
recorded: SectionState = { key: 'recorded', titleKey: 'themes.recorded', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
|
recorded: SectionState = { key: 'recorded', titleKey: 'themes.recorded', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
|
||||||
videos: SectionState = { key: 'videos', titleKey: 'themes.videos', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
|
videos: SectionState = { key: 'videos', titleKey: 'themes.videos', items: [], nextCursor: null, loading: false, error: null, visibleCount: 12 };
|
||||||
|
|
||||||
|
// Derived lists for UI separation
|
||||||
|
shorts: Video[] = []; // aggregated from both sections
|
||||||
|
nonShorts: Video[] = []; // from videos section only
|
||||||
|
recordedNonShorts: Video[] = []; // from recorded section
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.route.paramMap.subscribe(pm => {
|
this.route.paramMap.subscribe(pm => {
|
||||||
@ -86,9 +90,12 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
const apply = () => {
|
const apply = () => {
|
||||||
// Reset visible window to ensure top results reflect new sort/filter instantly
|
// Reset visible window to ensure top results reflect new sort/filter instantly
|
||||||
this.recorded.visibleCount = Math.max(20, this.recorded.visibleCount);
|
this.recorded.visibleCount = Math.max(20, this.recorded.visibleCount);
|
||||||
this.videos.visibleCount = Math.max(20, this.videos.visibleCount);
|
// Keep videos at 12-increment pagination
|
||||||
|
this.videos.visibleCount = Math.max(12, this.videos.visibleCount);
|
||||||
this.recorded.items = this.applyFilters(this.recorded.items);
|
this.recorded.items = this.applyFilters(this.recorded.items);
|
||||||
this.videos.items = this.applyFilters(this.videos.items);
|
this.videos.items = this.applyFilters(this.videos.items);
|
||||||
|
// Recompute derived partitions
|
||||||
|
this.partitionVideos();
|
||||||
// After filtering, re-evaluate if we need to prefetch more
|
// After filtering, re-evaluate if we need to prefetch more
|
||||||
this.checkPrefetch(this.recorded);
|
this.checkPrefetch(this.recorded);
|
||||||
this.checkPrefetch(this.videos);
|
this.checkPrefetch(this.videos);
|
||||||
@ -104,8 +111,12 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
|
|
||||||
private resetAll() {
|
private resetAll() {
|
||||||
for (const s of [this.recorded, this.videos]) {
|
for (const s of [this.recorded, this.videos]) {
|
||||||
s.items = []; s.nextCursor = null; s.loading = false; s.error = null; s.visibleCount = 20;
|
s.items = []; s.nextCursor = null; s.loading = false; s.error = null;
|
||||||
|
s.visibleCount = s.key === 'videos' ? 12 : 20;
|
||||||
}
|
}
|
||||||
|
this.shorts = [];
|
||||||
|
this.nonShorts = [];
|
||||||
|
this.recordedNonShorts = [];
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +186,125 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pagination des shorts
|
||||||
|
shortsVisibleCount = 20;
|
||||||
|
|
||||||
|
// Charge plus de shorts
|
||||||
|
showMoreShorts() {
|
||||||
|
this.shortsVisibleCount += 20;
|
||||||
|
// Vérifier si nous avons assez de vidéos pour afficher
|
||||||
|
if (this.shorts.length <= this.shortsVisibleCount) {
|
||||||
|
// Si nous n'avons pas assez de vidéos, essayer d'en charger plus
|
||||||
|
this.checkAndLoadMoreShorts();
|
||||||
|
}
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie et charge plus de shorts si nécessaire
|
||||||
|
private checkAndLoadMoreShorts() {
|
||||||
|
// Vérifier si nous avons déjà chargé toutes les vidéos disponibles
|
||||||
|
const totalShorts = this.videos.items.filter(v => this.isShortItem(v)).length +
|
||||||
|
this.recorded.items.filter(v => this.isShortItem(v)).length;
|
||||||
|
|
||||||
|
// Si nous avons moins de vidéos que ce que nous voulons afficher, essayer d'en charger plus
|
||||||
|
if (totalShorts <= this.shortsVisibleCount) {
|
||||||
|
// Essayer de charger plus de vidéos de la section videos d'abord
|
||||||
|
if (this.videos.nextCursor && !this.videos.loading) {
|
||||||
|
this.loadMore(this.videos);
|
||||||
|
}
|
||||||
|
// Puis essayer de charger plus de vidéos de la section recorded
|
||||||
|
else if (this.recorded.nextCursor && !this.recorded.loading) {
|
||||||
|
this.loadMore(this.recorded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classification per spec
|
||||||
|
private isShortItem(x: any): boolean {
|
||||||
|
try {
|
||||||
|
if (!x) return false;
|
||||||
|
if (x.type === 'short') return true;
|
||||||
|
if ((x as any).isShort === true) return true;
|
||||||
|
// Platform URL hints (best-effort)
|
||||||
|
const u = String((x as any).url || '');
|
||||||
|
if (u) {
|
||||||
|
// YouTube shorts URLs: /shorts/VIDEOID
|
||||||
|
if (/\/shorts\//i.test(u)) return true;
|
||||||
|
// General reels/clips hints used by some providers
|
||||||
|
if (/\b(reel|reels|clip|clips)\b/i.test(u)) return true;
|
||||||
|
}
|
||||||
|
const dur = Number((x as any).durationSeconds ?? x.duration ?? 0);
|
||||||
|
if (dur > 0 && dur <= 60) return true;
|
||||||
|
const ar = Number((x as any).aspectRatio ?? 0);
|
||||||
|
if (ar > 1.2) return true; // treat vertical as shorts if hinted
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private partitionVideos() {
|
||||||
|
const vids = this.videos.items || [];
|
||||||
|
const recs = this.recorded.items || [];
|
||||||
|
const shortsAgg: Video[] = [];
|
||||||
|
const vidsLongs: Video[] = [];
|
||||||
|
const recsLongs: Video[] = [];
|
||||||
|
const seenShort = new Set<string>();
|
||||||
|
|
||||||
|
const pushShort = (x: Video) => {
|
||||||
|
const k = x.videoId || x.url || '';
|
||||||
|
if (k && !seenShort.has(k)) {
|
||||||
|
seenShort.add(k);
|
||||||
|
shortsAgg.push(x);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filtrer les shorts et les vidéos longues pour les vidéos
|
||||||
|
for (const v of vids) {
|
||||||
|
if (this.isShortItem(v)) {
|
||||||
|
pushShort(v);
|
||||||
|
} else {
|
||||||
|
vidsLongs.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer les shorts et les vidéos longues pour les enregistrements
|
||||||
|
for (const r of recs) {
|
||||||
|
if (this.isShortItem(r)) {
|
||||||
|
pushShort(r);
|
||||||
|
} else {
|
||||||
|
recsLongs.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les tableaux de vidéos
|
||||||
|
this.shorts = shortsAgg;
|
||||||
|
|
||||||
|
// Conserver les vidéos déjà affichées si elles existent encore
|
||||||
|
const currentNonShorts = new Set(this.nonShorts.map(v => v.videoId || v.url || ''));
|
||||||
|
const currentRecordedNonShorts = new Set(this.recordedNonShorts.map(v => v.videoId || v.url || ''));
|
||||||
|
|
||||||
|
// Mettre à jour les vidéos non-courtes en conservant l'ordre et les éléments existants
|
||||||
|
this.nonShorts = [
|
||||||
|
...this.nonShorts.filter(v => vidsLongs.some(v2 => (v2.videoId || v2.url) === (v.videoId || v.url))),
|
||||||
|
...vidsLongs.filter(v => !currentNonShorts.has(v.videoId || v.url || ''))
|
||||||
|
].slice(0, this.videos.visibleCount);
|
||||||
|
|
||||||
|
// Mettre à jour les enregistrements non-courts en conservant l'ordre et les éléments existants
|
||||||
|
this.recordedNonShorts = [
|
||||||
|
...this.recordedNonShorts.filter(v => recsLongs.some(v2 => (v2.videoId || v2.url) === (v.videoId || v.url))),
|
||||||
|
...recsLongs.filter(v => !currentRecordedNonShorts.has(v.videoId || v.url || ''))
|
||||||
|
].slice(0, this.recorded.visibleCount);
|
||||||
|
|
||||||
|
// Mettre à jour le compteur de shorts visibles si nécessaire
|
||||||
|
if (this.shortsVisibleCount > this.shorts.length) {
|
||||||
|
this.shortsVisibleCount = this.shorts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forcer la détection des changements
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
changeProvider(p: any) {
|
changeProvider(p: any) {
|
||||||
const provider = String(p) as Provider;
|
const provider = String(p) as Provider;
|
||||||
// Sync the global provider so header search reflects the current context immediately
|
// Sync the global provider so header search reflects the current context immediately
|
||||||
@ -240,9 +370,21 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
const afterKeys = existing.size;
|
const afterKeys = existing.size;
|
||||||
const added = Math.max(0, afterKeys - beforeKeys);
|
const added = Math.max(0, afterKeys - beforeKeys);
|
||||||
const duplicates = Math.max(0, (incoming.length || 0) - added);
|
const duplicates = Math.max(0, (incoming.length || 0) - added);
|
||||||
|
|
||||||
|
// Mettre à jour les éléments de la section
|
||||||
section.items = this.applyFilters(merged);
|
section.items = this.applyFilters(merged);
|
||||||
section.nextCursor = res.nextCursor || null;
|
section.nextCursor = res.nextCursor || null;
|
||||||
section.loading = false;
|
section.loading = false;
|
||||||
|
|
||||||
|
// Mettre à jour les tableaux de vidéos visibles
|
||||||
|
if (section.key === 'videos') {
|
||||||
|
this.nonShorts = section.items.filter(v => !this.isShortItem(v)).slice(0, section.visibleCount);
|
||||||
|
} else if (section.key === 'recorded') {
|
||||||
|
this.recordedNonShorts = section.items.filter(v => !this.isShortItem(v)).slice(0, section.visibleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour les shorts si nécessaire
|
||||||
|
this.partitionVideos();
|
||||||
if (incoming.length === 0) {
|
if (incoming.length === 0) {
|
||||||
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
|
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
|
||||||
}
|
}
|
||||||
@ -269,6 +411,7 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
// Re-apply filters/sorts that depend on duration
|
// Re-apply filters/sorts that depend on duration
|
||||||
section.items = this.applyFilters(section.items);
|
section.items = this.applyFilters(section.items);
|
||||||
|
this.partitionVideos();
|
||||||
this.checkPrefetch(section);
|
this.checkPrefetch(section);
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
@ -277,6 +420,7 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
this.partitionVideos();
|
||||||
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
|
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
|
||||||
this.checkPrefetch(section);
|
this.checkPrefetch(section);
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
@ -312,16 +456,30 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
|
|
||||||
// Increase visible items by 20; fetch the next page if we need more data
|
// Increase visible items by 20; fetch the next page if we need more data
|
||||||
showMore(section: SectionState) {
|
showMore(section: SectionState) {
|
||||||
const target = section.visibleCount + 20;
|
const step = section.key === 'videos' ? 12 : 20;
|
||||||
|
const target = section.visibleCount + step;
|
||||||
section.visibleCount = target;
|
section.visibleCount = target;
|
||||||
// If we don't have enough items yet and there is a next page, fetch it
|
|
||||||
if (section.items.length < target && section.nextCursor && !section.loading) {
|
// Mettre à jour les vidéos visibles immédiatement
|
||||||
|
if (section.key === 'videos') {
|
||||||
|
this.nonShorts = this.videos.items.slice(0, section.visibleCount);
|
||||||
|
} else if (section.key === 'recorded') {
|
||||||
|
this.recordedNonShorts = this.recorded.items.slice(0, section.visibleCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si nous n'avons pas assez d'éléments et qu'il y a une page suivante, la charger
|
||||||
|
const poolLength = this.poolLengthFor(section);
|
||||||
|
if (poolLength < target && section.nextCursor && !section.loading) {
|
||||||
console.debug('[ProviderTheme] showMore triggers fetch', { section: section.key, target, have: section.items.length, nextCursor: section.nextCursor });
|
console.debug('[ProviderTheme] showMore triggers fetch', { section: section.key, target, have: section.items.length, nextCursor: section.nextCursor });
|
||||||
this.loadMore(section);
|
this.loadMore(section);
|
||||||
}
|
}
|
||||||
// Additionally, run logic-based prefetch check in case of edge conditions
|
|
||||||
|
// Vérifier également si nous devons charger plus de contenu
|
||||||
this.checkPrefetch(section);
|
this.checkPrefetch(section);
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
|
|
||||||
|
// Forcer la détection des changements pour mettre à jour l'interface utilisateur
|
||||||
|
try { this.cdr.detectChanges(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers for watch params
|
// Helpers for watch params
|
||||||
@ -363,13 +521,20 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
private checkPrefetch(section: SectionState) {
|
private checkPrefetch(section: SectionState) {
|
||||||
const cols = this.currentCols();
|
const cols = this.currentCols();
|
||||||
const threshold = cols * 2; // ~2 rows
|
const threshold = cols * 2; // ~2 rows
|
||||||
const remaining = section.items.length - section.visibleCount;
|
const pool = this.poolLengthFor(section);
|
||||||
|
const remaining = pool - section.visibleCount;
|
||||||
if (remaining <= threshold && section.nextCursor && !section.loading) {
|
if (remaining <= threshold && section.nextCursor && !section.loading) {
|
||||||
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
|
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
|
||||||
this.loadMore(section);
|
this.loadMore(section);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private poolLengthFor(section: SectionState): number {
|
||||||
|
if (section.key === 'videos') return this.nonShorts.length || 0;
|
||||||
|
if (section.key === 'recorded') return this.recordedNonShorts.length || 0;
|
||||||
|
return section.items.length;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
try { if (this.handleResize) window.removeEventListener('resize', this.handleResize); } catch {}
|
try { if (this.handleResize) window.removeEventListener('resize', this.handleResize); } catch {}
|
||||||
}
|
}
|
||||||
|