chore: update TypeScript build info cache
2
.angular/cache/20.2.2/app/.tsbuildinfo
vendored
@ -1,61 +1,61 @@
|
|||||||
{
|
{
|
||||||
"hash": "3cecccc1",
|
"hash": "70aeb477",
|
||||||
"configHash": "d859ec53",
|
"configHash": "d859ec53",
|
||||||
"lockfileHash": "80653b50",
|
"lockfileHash": "38d89503",
|
||||||
"browserHash": "cc6248f8",
|
"browserHash": "a9625742",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"@angular/common": {
|
"@angular/common": {
|
||||||
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
|
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
|
||||||
"file": "@angular_common.js",
|
"file": "@angular_common.js",
|
||||||
"fileHash": "c6e29582",
|
"fileHash": "0c3a1cb9",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/common/http": {
|
"@angular/common/http": {
|
||||||
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
|
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
|
||||||
"file": "@angular_common_http.js",
|
"file": "@angular_common_http.js",
|
||||||
"fileHash": "3a2fb905",
|
"fileHash": "56f4d3d3",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/core": {
|
"@angular/core": {
|
||||||
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
|
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
|
||||||
"file": "@angular_core.js",
|
"file": "@angular_core.js",
|
||||||
"fileHash": "236bf4f0",
|
"fileHash": "d890be7e",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/forms": {
|
"@angular/forms": {
|
||||||
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
|
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
|
||||||
"file": "@angular_forms.js",
|
"file": "@angular_forms.js",
|
||||||
"fileHash": "910649fe",
|
"fileHash": "905d0ee2",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/platform-browser": {
|
"@angular/platform-browser": {
|
||||||
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
|
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
|
||||||
"file": "@angular_platform-browser.js",
|
"file": "@angular_platform-browser.js",
|
||||||
"fileHash": "669d4d15",
|
"fileHash": "44676ec1",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@angular/router": {
|
"@angular/router": {
|
||||||
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
|
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
|
||||||
"file": "@angular_router.js",
|
"file": "@angular_router.js",
|
||||||
"fileHash": "697a6c94",
|
"fileHash": "c691e369",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"@google/genai": {
|
"@google/genai": {
|
||||||
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
|
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
|
||||||
"file": "@google_genai.js",
|
"file": "@google_genai.js",
|
||||||
"fileHash": "3ef22c22",
|
"fileHash": "cd430349",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"rxjs": {
|
"rxjs": {
|
||||||
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
|
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
|
||||||
"file": "rxjs.js",
|
"file": "rxjs.js",
|
||||||
"fileHash": "9c55814f",
|
"fileHash": "467e2c35",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"rxjs/operators": {
|
"rxjs/operators": {
|
||||||
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
|
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
|
||||||
"file": "rxjs_operators.js",
|
"file": "rxjs_operators.js",
|
||||||
"fileHash": "4e738648",
|
"fileHash": "9aa95ff5",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"hash": "75f2f65a",
|
"hash": "be6806eb",
|
||||||
"configHash": "3d00a7fd",
|
"configHash": "3d00a7fd",
|
||||||
"lockfileHash": "80653b50",
|
"lockfileHash": "38d89503",
|
||||||
"browserHash": "f89b65f9",
|
"browserHash": "f3292f98",
|
||||||
"optimized": {},
|
"optimized": {},
|
||||||
"chunks": {}
|
"chunks": {}
|
||||||
}
|
}
|
BIN
db/newtube.db
@ -8,7 +8,8 @@
|
|||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"preview": "ng serve --configuration=production",
|
"preview": "ng serve --configuration=production",
|
||||||
"api": "node --env-file=.env.local ./server/index.mjs",
|
"api": "node --env-file=.env.local ./server/index.mjs",
|
||||||
"api:watch": "node --watch --env-file=.env.local ./server/index.mjs"
|
"api:watch": "node --watch --env-file=.env.local ./server/index.mjs",
|
||||||
|
"test:playlists": "node ./server/tests/playlist_visibility.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/build": "^20.1.0",
|
"@angular/build": "^20.1.0",
|
||||||
|
Before Width: | Height: | Size: 1003 KiB After Width: | Height: | Size: 1003 KiB |
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 178 KiB |
Before Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 109 KiB |
BIN
public/images/default_playlist.png
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
public/images/default_playlist_1.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
@ -4,9 +4,12 @@ import { randomBytes, randomUUID } from 'node:crypto';
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const dbDir = path.join(root, 'db');
|
const overrideDbFile = process.env.NEWTUBE_DB_FILE && String(process.env.NEWTUBE_DB_FILE).trim().length
|
||||||
const dbFile = path.join(dbDir, 'newtube.db');
|
? path.resolve(String(process.env.NEWTUBE_DB_FILE))
|
||||||
const schemaFile = path.join(dbDir, 'schema.sql');
|
: null;
|
||||||
|
const dbDir = overrideDbFile ? path.dirname(overrideDbFile) : path.join(root, 'db');
|
||||||
|
const dbFile = overrideDbFile || path.join(dbDir, 'newtube.db');
|
||||||
|
const schemaFile = path.join(root, 'db', 'schema.sql');
|
||||||
|
|
||||||
if (!fs.existsSync(dbDir)) {
|
if (!fs.existsSync(dbDir)) {
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
@ -460,11 +463,39 @@ export function listPlaylists({ userId, limit = 50, offset = 0, q }) {
|
|||||||
: db.prepare(sql).all(userId, limit, offset);
|
: db.prepare(sql).all(userId, limit, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List public playlists (visible to everyone)
|
||||||
|
export function listPublicPlaylists({ limit = 50, offset = 0, q }) {
|
||||||
|
const like = `%${(q || '').trim()}%`;
|
||||||
|
const hasQ = typeof q === 'string' && q.trim().length > 0;
|
||||||
|
const base = `SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt,
|
||||||
|
(SELECT COUNT(1) FROM playlist_items WHERE playlist_id = playlists.id) AS itemsCount
|
||||||
|
FROM playlists WHERE is_private = 0`;
|
||||||
|
const sql = hasQ ? `${base} AND (name LIKE ? OR COALESCE(description,'') LIKE ?)
|
||||||
|
ORDER BY updated_at DESC LIMIT ? OFFSET ?` :
|
||||||
|
`${base} ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
|
||||||
|
return hasQ
|
||||||
|
? db.prepare(sql).all(like, like, limit, offset)
|
||||||
|
: db.prepare(sql).all(limit, offset);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPlaylistRaw(id) {
|
export function getPlaylistRaw(id) {
|
||||||
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt
|
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt
|
||||||
FROM playlists WHERE id = ?`).get(id);
|
FROM playlists WHERE id = ?`).get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get playlist with items if allowed for a given viewer (owner or public)
|
||||||
|
export function getPlaylistWithItemsIfAllowed({ viewerUserId, id, limit = 200, offset = 0 }) {
|
||||||
|
const pl = db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt
|
||||||
|
FROM playlists WHERE id = ?`).get(id);
|
||||||
|
if (!pl) return null;
|
||||||
|
const isOwner = viewerUserId && pl.userId === viewerUserId;
|
||||||
|
if (!isOwner && Number(pl.isPrivate) === 1) return 'forbidden';
|
||||||
|
const items = db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position
|
||||||
|
FROM playlist_items WHERE playlist_id = ?
|
||||||
|
ORDER BY position ASC LIMIT ? OFFSET ?`).all(id, limit, offset);
|
||||||
|
return { ...pl, items };
|
||||||
|
}
|
||||||
|
|
||||||
export function updatePlaylist({ userId, id, patch }) {
|
export function updatePlaylist({ userId, id, patch }) {
|
||||||
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
|
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
|
||||||
if (!cur) return null;
|
if (!cur) return null;
|
||||||
|
@ -44,7 +44,9 @@ import {
|
|||||||
// Playlists
|
// Playlists
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
listPlaylists,
|
listPlaylists,
|
||||||
|
listPublicPlaylists,
|
||||||
getPlaylistRaw,
|
getPlaylistRaw,
|
||||||
|
getPlaylistWithItemsIfAllowed,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
listPlaylistItems,
|
listPlaylistItems,
|
||||||
@ -60,6 +62,46 @@ const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15);
|
|||||||
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
|
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
|
||||||
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
|
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
|
||||||
|
|
||||||
|
// Create router
|
||||||
|
const r = express.Router();
|
||||||
|
|
||||||
|
// Public: list public playlists (no auth required)
|
||||||
|
r.get('/playlists/public', (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
||||||
|
const offset = Math.max(0, Number(req.query.offset || 0));
|
||||||
|
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||||
|
const rows = listPublicPlaylists({ limit, offset, q });
|
||||||
|
return res.json(rows);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'list_public_failed', details: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
|
||||||
|
r.get('/playlists/:id/view', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = String(req.params.id || '');
|
||||||
|
let viewerUserId = undefined;
|
||||||
|
try {
|
||||||
|
const auth = req.headers['authorization'] || '';
|
||||||
|
const [, token] = String(auth).split(' ');
|
||||||
|
if (token) {
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
viewerUserId = payload?.sub;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500)));
|
||||||
|
const offset = Math.max(0, Number(req.query.offset || 0));
|
||||||
|
const result = getPlaylistWithItemsIfAllowed({ viewerUserId, id, limit, offset });
|
||||||
|
if (result === 'forbidden') return res.status(404).json({ error: 'not_found' });
|
||||||
|
if (!result) return res.status(404).json({ error: 'not_found' });
|
||||||
|
return res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: 'view_failed', details: String(e?.message || e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Servir les fichiers statiques du dossier dist
|
// Servir les fichiers statiques du dossier dist
|
||||||
app.use(express.static(path.join(process.cwd(), 'dist')));
|
app.use(express.static(path.join(process.cwd(), 'dist')));
|
||||||
app.use('/assets', express.static(path.join(process.cwd(), 'assets')));
|
app.use('/assets', express.static(path.join(process.cwd(), 'assets')));
|
||||||
@ -528,7 +570,6 @@ function formatListFromMeta(meta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Routes under /api
|
// Routes under /api
|
||||||
const r = express.Router();
|
|
||||||
|
|
||||||
// -------------------- YouTube simple cache (GET) --------------------
|
// -------------------- YouTube simple cache (GET) --------------------
|
||||||
// Cache TTL defaults to 15 minutes, configurable via env YT_CACHE_TTL_MS
|
// Cache TTL defaults to 15 minutes, configurable via env YT_CACHE_TTL_MS
|
||||||
|
94
server/tests/playlist_visibility.test.mjs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// Simple integration test for playlist public/private visibility
|
||||||
|
// Run with: npm run test:playlists
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
// Create isolated temp DB file
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-test-'));
|
||||||
|
const dbPath = path.join(tmpDir, 'test.db');
|
||||||
|
process.env.NEWTUBE_DB_FILE = dbPath;
|
||||||
|
|
||||||
|
// Dynamically import DB after setting env
|
||||||
|
const dbMod = await import('../db.mjs');
|
||||||
|
const {
|
||||||
|
insertUser,
|
||||||
|
cryptoRandomUUID,
|
||||||
|
createPlaylist,
|
||||||
|
listPublicPlaylists,
|
||||||
|
listPlaylists,
|
||||||
|
getPlaylistWithItemsIfAllowed,
|
||||||
|
} = dbMod;
|
||||||
|
|
||||||
|
function expect(cond, msg) {
|
||||||
|
if (!cond) {
|
||||||
|
throw new Error(`Assertion failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logOk(msg) {
|
||||||
|
console.log(`✓ ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed users
|
||||||
|
const ownerId = cryptoRandomUUID();
|
||||||
|
const otherId = cryptoRandomUUID();
|
||||||
|
insertUser({ id: ownerId, username: 'owner', email: 'owner@example.com', passwordHash: 'x' });
|
||||||
|
insertUser({ id: otherId, username: 'other', email: 'other@example.com', passwordHash: 'y' });
|
||||||
|
|
||||||
|
// Seed playlists
|
||||||
|
const pub1 = createPlaylist({ userId: ownerId, title: 'Pub A', description: 'public', thumbnail: null, isPrivate: false });
|
||||||
|
const pub2 = createPlaylist({ userId: ownerId, title: 'Pub B', description: 'public', thumbnail: null, isPrivate: 0 });
|
||||||
|
const priv1 = createPlaylist({ userId: ownerId, title: 'Priv A', description: 'private', thumbnail: null, isPrivate: true });
|
||||||
|
|
||||||
|
// 1) Anonymous lists public
|
||||||
|
{
|
||||||
|
const pubs = listPublicPlaylists({ limit: 50, offset: 0 });
|
||||||
|
expect(Array.isArray(pubs), 'listPublicPlaylists returns array');
|
||||||
|
const ids = new Set(pubs.map(p => p.id));
|
||||||
|
expect(ids.has(pub1.id) && ids.has(pub2.id), 'public playlists visible');
|
||||||
|
expect(!ids.has(priv1.id), 'private playlist not visible');
|
||||||
|
logOk('Anonymous sees public playlists only');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Other user lists own (none) + sees public via listPublicPlaylists
|
||||||
|
{
|
||||||
|
const ownOther = listPlaylists({ userId: otherId, limit: 50, offset: 0 });
|
||||||
|
expect(ownOther.length === 0, 'other user has no own playlists');
|
||||||
|
const pubs = listPublicPlaylists({ limit: 50, offset: 0 });
|
||||||
|
const ids = new Set(pubs.map(p => p.id));
|
||||||
|
expect(ids.has(pub1.id) && ids.has(pub2.id), 'other user sees public playlists');
|
||||||
|
logOk('Other user sees public playlists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Owner lists own (public + private)
|
||||||
|
{
|
||||||
|
const ownOwner = listPlaylists({ userId: ownerId, limit: 50, offset: 0 });
|
||||||
|
const ids = new Set(ownOwner.map(p => p.id));
|
||||||
|
expect(ids.has(pub1.id) && ids.has(pub2.id) && ids.has(priv1.id), 'owner sees all own playlists, including private');
|
||||||
|
logOk('Owner sees own public + private playlists');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Public view endpoint logic: getPlaylistWithItemsIfAllowed
|
||||||
|
{
|
||||||
|
// Anonymous can view public
|
||||||
|
const viewPub = getPlaylistWithItemsIfAllowed({ viewerUserId: undefined, id: pub1.id, limit: 10, offset: 0 });
|
||||||
|
expect(viewPub && viewPub.id === pub1.id, 'anonymous can view public playlist details');
|
||||||
|
|
||||||
|
// Anonymous cannot view private
|
||||||
|
const viewPriv = getPlaylistWithItemsIfAllowed({ viewerUserId: undefined, id: priv1.id, limit: 10, offset: 0 });
|
||||||
|
expect(viewPriv === 'forbidden', 'anonymous cannot view private playlist');
|
||||||
|
|
||||||
|
// Other user can view public
|
||||||
|
const viewPubOther = getPlaylistWithItemsIfAllowed({ viewerUserId: otherId, id: pub2.id, limit: 10, offset: 0 });
|
||||||
|
expect(viewPubOther && viewPubOther.id === pub2.id, 'other user can view public playlist');
|
||||||
|
|
||||||
|
// Owner can view private
|
||||||
|
const viewPrivOwner = getPlaylistWithItemsIfAllowed({ viewerUserId: ownerId, id: priv1.id, limit: 10, offset: 0 });
|
||||||
|
expect(viewPrivOwner && viewPrivOwner.id === priv1.id, 'owner can view own private playlist');
|
||||||
|
|
||||||
|
logOk('Public view behavior correct for anonymous, other, and owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nAll playlist visibility tests passed.');
|
@ -1,7 +1,9 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/playlists.service';
|
import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/playlists.service';
|
||||||
|
import { AuthService } from '../../../services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-playlist-detail',
|
selector: 'app-playlist-detail',
|
||||||
@ -11,7 +13,7 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
|
|||||||
<div class="container mx-auto p-4 sm:p-6">
|
<div class="container mx-auto p-4 sm:p-6">
|
||||||
<div class="flex items-center justify-between gap-3 mb-4">
|
<div class="flex items-center justify-between gap-3 mb-4">
|
||||||
<a routerLink="/library/playlists" class="text-slate-300 hover:text-slate-100">← Retour</a>
|
<a routerLink="/library/playlists" class="text-slate-300 hover:text-slate-100">← Retour</a>
|
||||||
<div class="flex items-center gap-2" *ngIf="dirty()">
|
<div class="flex items-center gap-2" *ngIf="dirty() && isOwner()">
|
||||||
<button (click)="saveOrder()" [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold">
|
<button (click)="saveOrder()" [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold">
|
||||||
{{ saving() ? 'Enregistrement...' : 'Enregistrer l\'ordre' }}
|
{{ saving() ? 'Enregistrement...' : 'Enregistrer l\'ordre' }}
|
||||||
</button>
|
</button>
|
||||||
@ -41,10 +43,7 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
|
|||||||
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
<div class="flex flex-col sm:flex-row gap-4 mb-6">
|
||||||
<div class="w-full sm:w-64">
|
<div class="w-full sm:w-64">
|
||||||
<div class="rounded overflow-hidden border border-slate-700 bg-slate-800">
|
<div class="rounded overflow-hidden border border-slate-700 bg-slate-800">
|
||||||
<img *ngIf="playlist()?.thumbnail; else noThumb" [src]="playlist()!.thumbnail!" alt="" class="w-full h-40 object-cover"/>
|
<img [src]="playlist()?.thumbnail || DEFAULT_PLAYLIST_IMAGE" [alt]="playlist()?.title || 'Playlist'" class="w-full h-40 object-cover"/>
|
||||||
<ng-template #noThumb>
|
|
||||||
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800"></div>
|
|
||||||
</ng-template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@ -60,16 +59,13 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div *ngFor="let it of items(); let i = index" class="flex items-center gap-3 p-2 rounded border border-slate-700 bg-slate-800/60">
|
<div *ngFor="let it of items(); let i = index" class="flex items-center gap-3 p-2 rounded border border-slate-700 bg-slate-800/60">
|
||||||
<div class="w-28 flex-shrink-0">
|
<div class="w-28 flex-shrink-0">
|
||||||
<img *ngIf="it.thumbnail; else noThumb2" [src]="it.thumbnail!" alt="" class="w-28 h-16 object-cover rounded">
|
<img [src]="it.thumbnail || DEFAULT_PLAYLIST_IMAGE" [alt]="it.title || 'Video thumbnail'" class="w-28 h-16 object-cover rounded">
|
||||||
<ng-template #noThumb2>
|
|
||||||
<div class="w-28 h-16 bg-slate-700 rounded"></div>
|
|
||||||
</ng-template>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<a [routerLink]="['/watch', it.videoId]" [queryParams]="{ p: it.provider }" class="font-semibold text-slate-100 hover:text-red-400 line-clamp-2">{{ it.title || it.videoId }}</a>
|
<a [routerLink]="['/watch', it.videoId]" [queryParams]="{ p: it.provider }" class="font-semibold text-slate-100 hover:text-red-400 line-clamp-2">{{ it.title || it.videoId }}</a>
|
||||||
<div class="text-xs text-slate-400 mt-1">{{ it.provider }} • Pos. {{ i + 1 }}</div>
|
<div class="text-xs text-slate-400 mt-1">{{ it.provider }} • Pos. {{ i + 1 }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2" *ngIf="isOwner()">
|
||||||
<button (click)="moveUp(i)" [disabled]="i===0" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↑</button>
|
<button (click)="moveUp(i)" [disabled]="i===0" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↑</button>
|
||||||
<button (click)="moveDown(i)" [disabled]="i===items().length-1" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↓</button>
|
<button (click)="moveDown(i)" [disabled]="i===items().length-1" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↓</button>
|
||||||
<button (click)="removeItem(it)" class="px-2 py-1 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Retirer</button>
|
<button (click)="removeItem(it)" class="px-2 py-1 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Retirer</button>
|
||||||
@ -82,8 +78,16 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class PlaylistDetailComponent {
|
export class PlaylistDetailComponent {
|
||||||
|
// Default image path
|
||||||
|
readonly DEFAULT_PLAYLIST_IMAGE = '/images/default_playlist.png';
|
||||||
|
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
private http = inject(HttpClient);
|
||||||
private api = inject(PlaylistsService);
|
private api = inject(PlaylistsService);
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
// Expose a bound accessor to avoid losing 'this' context
|
||||||
|
currentUser = () => this.auth.currentUser();
|
||||||
|
|
||||||
loading = signal<boolean>(true);
|
loading = signal<boolean>(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
@ -91,6 +95,7 @@ export class PlaylistDetailComponent {
|
|||||||
items = signal<PlaylistItem[]>([]);
|
items = signal<PlaylistItem[]>([]);
|
||||||
dirty = signal<boolean>(false);
|
dirty = signal<boolean>(false);
|
||||||
saving = signal<boolean>(false);
|
saving = signal<boolean>(false);
|
||||||
|
isOwner = signal<boolean>(false);
|
||||||
|
|
||||||
private id = signal<string>('');
|
private id = signal<string>('');
|
||||||
|
|
||||||
@ -103,14 +108,24 @@ export class PlaylistDetailComponent {
|
|||||||
load(id: string) {
|
load(id: string) {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.api.get(id, 1000, 0).subscribe({
|
const onLoad = (pl: any) => {
|
||||||
next: (pl) => {
|
|
||||||
this.playlist.set(pl);
|
this.playlist.set(pl);
|
||||||
this.items.set((pl.items || []).slice().sort((a, b) => a.position - b.position));
|
this.items.set((pl.items || []).slice().sort((a: PlaylistItem, b: PlaylistItem) => a.position - b.position));
|
||||||
|
const user = this.currentUser();
|
||||||
|
const uid = user?.id;
|
||||||
|
this.isOwner.set(Boolean(uid && pl && pl.userId === uid));
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.dirty.set(false);
|
this.dirty.set(false);
|
||||||
},
|
};
|
||||||
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
|
this.api.get(id, 1000, 0).subscribe({
|
||||||
|
next: onLoad,
|
||||||
|
error: (_err) => {
|
||||||
|
// Fallback to public view
|
||||||
|
this.api.view(id, 1000, 0).subscribe({
|
||||||
|
next: onLoad,
|
||||||
|
error: (err2) => { this.error.set(err2?.error?.error || err2?.message || 'Erreur de chargement'); this.loading.set(false); }
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,7 +150,7 @@ export class PlaylistDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveOrder() {
|
saveOrder() {
|
||||||
if (!this.dirty() || !this.id()) return;
|
if (!this.isOwner() || !this.dirty() || !this.id()) return;
|
||||||
this.saving.set(true);
|
this.saving.set(true);
|
||||||
const order = this.items().map(it => it.id);
|
const order = this.items().map(it => it.id);
|
||||||
this.api.reorder(this.id(), order).subscribe({
|
this.api.reorder(this.id(), order).subscribe({
|
||||||
@ -145,6 +160,7 @@ export class PlaylistDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeItem(it: PlaylistItem) {
|
removeItem(it: PlaylistItem) {
|
||||||
|
if (!this.isOwner()) return;
|
||||||
const ok = confirm('Retirer cette vidéo de la playlist ?');
|
const ok = confirm('Retirer cette vidéo de la playlist ?');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
this.api.removeVideo(this.id(), it.provider, it.videoId).subscribe({
|
this.api.removeVideo(this.id(), it.provider, it.videoId).subscribe({
|
||||||
|
@ -37,13 +37,7 @@
|
|||||||
<div class="bg-slate-800/70 rounded-lg overflow-hidden border border-slate-700 flex flex-col">
|
<div class="bg-slate-800/70 rounded-lg overflow-hidden border border-slate-700 flex flex-col">
|
||||||
<a [routerLink]="['/library/playlists', pl.id]" class="block group">
|
<a [routerLink]="['/library/playlists', pl.id]" class="block group">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@if (pl.thumbnail) {
|
<img [src]="pl.thumbnail || DEFAULT_PLAYLIST_IMAGE" [alt]="pl.title" class="w-full h-40 object-cover group-hover:opacity-90 transition" />
|
||||||
<img [src]="pl.thumbnail!" alt="" class="w-full h-40 object-cover group-hover:opacity-90 transition" />
|
|
||||||
} @else {
|
|
||||||
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800 flex items-center justify-center text-slate-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" viewBox="0 0 24 24" fill="currentColor"><path d="M4 5a2 2 0 00-2 2v7.5A2.5 2.5 0 004.5 17H20a2 2 0 002-2V7a2 2 0 00-2-2H4zm0 2h16v8H4.5a.5.5 0 01-.5-.5V7z"/><path d="M12 8.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z"/></svg>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="p-4 flex-1 flex flex-col">
|
<div class="p-4 flex-1 flex flex-col">
|
||||||
@ -58,8 +52,10 @@
|
|||||||
<span>MAJ: {{ (pl.updatedAt || '').slice(0,10) }}</span>
|
<span>MAJ: {{ (pl.updatedAt || '').slice(0,10) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center gap-2">
|
<div class="mt-3 flex items-center gap-2">
|
||||||
|
@if (currentUser() && pl.userId === currentUser()?.id) {
|
||||||
<button (click)="openEdit(pl)" class="px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">Modifier</button>
|
<button (click)="openEdit(pl)" class="px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">Modifier</button>
|
||||||
<button (click)="deletePlaylist(pl)" class="px-3 py-1.5 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Supprimer</button>
|
<button (click)="deletePlaylist(pl)" class="px-3 py-1.5 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Supprimer</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AuthService } from '../../../services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-library-playlists',
|
selector: 'app-library-playlists',
|
||||||
@ -12,13 +13,20 @@ import { RouterLink } from '@angular/router';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class PlaylistsComponent {
|
export class PlaylistsComponent {
|
||||||
|
// Default image path
|
||||||
|
readonly DEFAULT_PLAYLIST_IMAGE = '/images/default_playlist.png';
|
||||||
|
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
|
||||||
|
// Expose a bound accessor to avoid losing 'this' context when used in template
|
||||||
|
currentUser = () => this.auth.currentUser();
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
loading = signal<boolean>(true);
|
loading = signal<boolean>(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
searchQuery = signal<string>('');
|
searchQuery = signal<string>('');
|
||||||
playlists = signal<Array<{ id: string; title: string; description?: string | null; thumbnail?: string | null; isPrivate: number | boolean; createdAt: string; updatedAt: string; itemsCount: number }>>([]);
|
playlists = signal<Array<{ id: string; userId?: string; title: string; description?: string | null; thumbnail?: string | null; isPrivate: number | boolean; createdAt: string; updatedAt: string; itemsCount: number }>>([]);
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
modalOpen = signal<boolean>(false);
|
modalOpen = signal<boolean>(false);
|
||||||
@ -45,10 +53,32 @@ export class PlaylistsComponent {
|
|||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
const params: any = { limit: 200 };
|
const params: any = { limit: 200 };
|
||||||
if (q && q.trim()) params.q = q.trim();
|
if (q && q.trim()) params.q = q.trim();
|
||||||
this.http.get<any[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true }).subscribe({
|
const own$ = this.http.get<any[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true });
|
||||||
next: (rows) => { this.playlists.set(rows || []); this.loading.set(false); },
|
const pub$ = this.http.get<any[]>(`${this.apiBase()}/playlists/public`, { params, withCredentials: true });
|
||||||
|
let own: any[] = [];
|
||||||
|
let pub: any[] = [];
|
||||||
|
let ownErr: any = null;
|
||||||
|
// Fetch own (may 401 for anonymous)
|
||||||
|
own$.subscribe({
|
||||||
|
next: (rows) => { own = rows || []; tryMerge(); },
|
||||||
|
error: () => { own = []; ownErr = true; tryMerge(); }
|
||||||
|
});
|
||||||
|
// Fetch public
|
||||||
|
pub$.subscribe({
|
||||||
|
next: (rows) => { pub = rows || []; tryMerge(); },
|
||||||
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
|
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tryMerge = () => {
|
||||||
|
// Wait until we got public at least (own may be empty or errored)
|
||||||
|
if (pub === null) return;
|
||||||
|
const map = new Map<string, any>();
|
||||||
|
for (const p of pub) if (p && p.id) map.set(p.id, p);
|
||||||
|
for (const o of own) if (o && o.id) map.set(o.id, o);
|
||||||
|
const merged = Array.from(map.values());
|
||||||
|
this.playlists.set(merged);
|
||||||
|
this.loading.set(false);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearchChange(event: Event) {
|
onSearchChange(event: Event) {
|
||||||
@ -88,6 +118,9 @@ export class PlaylistsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openEdit(pl: any) {
|
openEdit(pl: any) {
|
||||||
|
// Only owners can edit
|
||||||
|
const user = this.currentUser();
|
||||||
|
if (!user?.id || pl.userId !== user.id) return;
|
||||||
this.editId.set(pl.id);
|
this.editId.set(pl.id);
|
||||||
this.formTitle.set(pl.title || '');
|
this.formTitle.set(pl.title || '');
|
||||||
this.formDescription.set(pl.description || '');
|
this.formDescription.set(pl.description || '');
|
||||||
@ -121,7 +154,8 @@ export class PlaylistsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deletePlaylist(pl: any) {
|
deletePlaylist(pl: any) {
|
||||||
if (!pl?.id) return;
|
const user = this.currentUser();
|
||||||
|
if (!pl?.id || !user?.id || pl.userId !== user.id) return;
|
||||||
const ok = confirm(`Supprimer la playlist "${pl.title}" ?`);
|
const ok = confirm(`Supprimer la playlist "${pl.title}" ?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
this.http.delete(`${this.apiBase()}/playlists/${encodeURIComponent(pl.id)}`, { withCredentials: true }).subscribe({
|
this.http.delete(`${this.apiBase()}/playlists/${encodeURIComponent(pl.id)}`, { withCredentials: true }).subscribe({
|
||||||
|
@ -21,6 +21,12 @@
|
|||||||
<span [class.hidden]="collapsed">{{ 'nav.shorts' | t }}</span>
|
<span [class.hidden]="collapsed">{{ 'nav.shorts' | t }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a routerLink="/library/playlists" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.playlists' | t) : null">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h12v2H4V6zm0 4h12v2H4v-2zm0 4h8v2H4v-2zm14-6h2v8h-2v-8z"/></svg>
|
||||||
|
<span [class.hidden]="collapsed">{{ 'nav.playlists' | t }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -47,12 +53,6 @@
|
|||||||
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
|
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a routerLink="/library/playlists" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.playlists' | t) : null">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h12v2H4V6zm0 4h12v2H4v-2zm0 4h8v2H4v-2zm14-6h2v8h-2v-8z"/></svg>
|
|
||||||
<span [class.hidden]="collapsed">{{ 'nav.playlists' | t }}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -1,33 +1,43 @@
|
|||||||
<div class="container mx-auto p-4 sm:p-6">
|
<div class="container mx-auto p-4 sm:p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div class="flex items-center gap-2 text-slate-200">
|
<div class="flex items-center gap-3 text-slate-200">
|
||||||
<a [routerLink]="['/t', theme()]" class="px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">← {{ 'themes.backToThemes' | t }}</a>
|
<a [routerLink]="['/t', theme()]" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition-colors">
|
||||||
<h2 class="text-2xl font-bold">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
{{ themes.bySlug(theme())?.emoji }} {{ themes.bySlug(theme())?.label }}
|
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||||
<span class="text-slate-400 text-base">— {{ provider() | titlecase }}</span>
|
</svg>
|
||||||
</h2>
|
</a>
|
||||||
|
<h1 class="text-3xl font-bold">
|
||||||
|
<span class="text-4xl mr-2">{{ themes.bySlug(theme())?.emoji }}</span>
|
||||||
|
{{ themes.bySlug(theme())?.label }}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-slate-400">{{ 'themes.changeProvider' | t }}</label>
|
<label class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
|
||||||
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="bg-gray-800 text-white rounded px-2 py-1">
|
<div class="relative">
|
||||||
|
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="appearance-none bg-slate-800 text-white rounded-md px-3 py-2 pr-8 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all">
|
||||||
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
|
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<svg class="w-5 h-5 absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="grid sm:grid-cols-4 gap-3 mb-6 bg-slate-800/50 p-3 rounded-lg border border-slate-700">
|
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.sort' | t }}</label>
|
<label for="sort-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
|
||||||
<select [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
<select id="sort-filter" [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
|
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
|
||||||
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
|
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
|
||||||
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
|
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.duration' | t }}</label>
|
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.duration' | t }}</label>
|
||||||
<select [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
<select id="duration-filter" [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
<option value="any">{{ 'filter.duration.any' | t }}</option>
|
<option value="any">{{ 'filter.duration.any' | t }}</option>
|
||||||
<option value="short">{{ 'filter.duration.short' | t }}</option>
|
<option value="short">{{ 'filter.duration.short' | t }}</option>
|
||||||
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
|
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
|
||||||
@ -35,16 +45,26 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.language' | t }}</label>
|
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
|
||||||
<select [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
<select id="type-filter" [ngModel]="type()" (ngModelChange)="onTypeChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<option value="any">Any</option>
|
||||||
|
<option value="video">Video</option>
|
||||||
|
<option value="live">Live</option>
|
||||||
|
<option value="recorded">Recorded</option>
|
||||||
|
<option value="short">Shorts</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
|
||||||
|
<select id="language-filter" [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
<option value="any">Any</option>
|
<option value="any">Any</option>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="fr">Français</option>
|
<option value="fr">Français</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.date' | t }}</label>
|
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
|
||||||
<select [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
|
<select id="date-filter" [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
<option value="any">Any</option>
|
<option value="any">Any</option>
|
||||||
<option value="day">Last 24h</option>
|
<option value="day">Last 24h</option>
|
||||||
<option value="week">Last week</option>
|
<option value="week">Last week</option>
|
||||||
@ -53,126 +73,68 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Sections Tabs mimic (simple headings) -->
|
<!-- Video Grid -->
|
||||||
<div class="space-y-10">
|
<div *ngIf="loading() && videos().length === 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||||
|
<div *ngFor="let i of [1,2,3,4,5,6,7,8,9,10]" class="animate-pulse">
|
||||||
|
<div class="w-full aspect-video bg-slate-800 rounded-lg"></div>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<div class="h-4 bg-slate-800 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-slate-800 rounded w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Videos -->
|
<div *ngIf="!loading() && videos().length === 0" class="text-center py-16">
|
||||||
<section>
|
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<h2 class="text-xl font-semibold mb-3">{{ 'themes.videos' | t }}</h2>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
<div *ngIf="videos.loading && !nonShorts.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
</svg>
|
||||||
<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">
|
<h3 class="mt-2 text-lg font-medium text-slate-300">{{ 'empty.noItems' | t }}</h3>
|
||||||
<div class="w-full h-32 bg-slate-800"></div>
|
<p class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
|
||||||
<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 *ngIf="error()" class="mt-4 p-4 bg-red-900/30 border border-red-700 text-red-200 rounded-lg text-center">
|
||||||
|
<p>{{ error() }}</p>
|
||||||
|
<button class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div *ngIf="!videos.loading && nonShorts.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
|
||||||
<div *ngIf="videos.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
|
<div *ngFor="let v of videos(); trackBy: trackByVideo" class="group">
|
||||||
{{ videos.error }}
|
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
|
||||||
<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">
|
<div class="relative">
|
||||||
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
|
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
|
||||||
<div class="absolute bottom-2 left-2">
|
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
|
||||||
|
{{ v.duration | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-2 left-2">
|
||||||
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
|
<div class="p-3">
|
||||||
|
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3>
|
||||||
|
<div class="mt-2 flex items-center space-x-2 text-xs text-slate-400">
|
||||||
|
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="w-6 h-6 rounded-full">
|
||||||
|
<span>{{ v.uploaderName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 text-xs text-slate-400">
|
||||||
|
<span>{{ v.views | number }} visionnements</span> • <span>{{ v.uploadedDate | date:'shortDate' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3" *ngIf="nonShorts.length > 20">
|
</div>
|
||||||
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200"
|
|
||||||
(click)="showMore(videos)"
|
<div *ngIf="loading() && videos().length > 0" class="text-center py-8">
|
||||||
aria-label="Afficher plus de vidéos">
|
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
{{ 'loading.more' | t }} ({{nonShorts.length - 20}})
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="nextCursor()" class="text-center mt-8">
|
||||||
|
<button (click)="loadMore()" [disabled]="loading()" class="px-6 py-3 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 text-slate-200 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all">
|
||||||
|
{{ 'loading.more' | t }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
|
||||||
<section>
|
|
||||||
<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 *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="!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">
|
|
||||||
{{ recorded.error }}
|
|
||||||
<button class="ml-2 underline hover:text-red-100" (click)="retry(recorded)">{{ 'action.retry' | t }}</button>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="recordedNonShorts.length">
|
|
||||||
<a *ngFor="let v of (recordedNonShorts | 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="recordedNonShorts.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(recorded)"
|
|
||||||
aria-label="Afficher plus de vidéos enregistrées">
|
|
||||||
{{ 'loading.more' | t }} ({{recordedNonShorts.length - 20}})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Categories (static recommended) -->
|
|
||||||
<section>
|
|
||||||
<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 *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="mt-2 font-semibold">{{ c.label }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
@ -9,16 +9,6 @@ import { Video } from '../../models/video.model';
|
|||||||
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';
|
||||||
|
|
||||||
interface SectionState {
|
|
||||||
key: 'recorded' | 'videos' | 'categories';
|
|
||||||
titleKey: string;
|
|
||||||
items: Video[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
visibleCount: number; // how many items are currently shown
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-provider-theme',
|
selector: 'app-provider-theme',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@ -37,27 +27,25 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
provider = signal<Provider>('youtube');
|
provider = signal<Provider>('youtube');
|
||||||
theme = signal<string>('trending');
|
theme = signal<string>('trending');
|
||||||
|
|
||||||
// Guard to ignore stale async responses after route/theme/provider changes
|
// Guard to ignore stale async responses
|
||||||
private version = 0;
|
private version = 0;
|
||||||
|
|
||||||
// Filters state
|
// Filters state
|
||||||
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
|
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
|
||||||
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
|
||||||
|
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short'>('any');
|
||||||
language = signal<'any' | 'en' | 'fr'>('any');
|
language = signal<'any' | 'en' | 'fr'>('any');
|
||||||
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
|
||||||
|
|
||||||
// UI
|
// Data state
|
||||||
|
allVideos = signal<Video[]>([]);
|
||||||
|
nextCursor = signal<string | null>(null);
|
||||||
|
loading = signal(false);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Derived state for the UI
|
||||||
providersList = computed(() => this.instances.providers());
|
providersList = computed(() => this.instances.providers());
|
||||||
categories = computed(() => this.themes.categoriesFor(this.theme()));
|
videos = computed(() => this.applyFilters(this.allVideos()));
|
||||||
|
|
||||||
// Sections (Live removed on provider pages)
|
|
||||||
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: 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 => {
|
||||||
@ -65,424 +53,150 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
const theme = pm.get('theme') || 'trending';
|
const theme = pm.get('theme') || 'trending';
|
||||||
this.provider.set(provider);
|
this.provider.set(provider);
|
||||||
this.theme.set(theme);
|
this.theme.set(theme);
|
||||||
// Keep global provider in sync so header search uses the current context
|
this.instances.setSelectedProvider(provider);
|
||||||
try { this.instances.setSelectedProvider(provider); } catch {}
|
this.resetAndLoad();
|
||||||
this.resetAll();
|
|
||||||
// Increment version so in-flight requests from previous state are ignored
|
|
||||||
this.version++;
|
|
||||||
this.loadInitial();
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prefetch recalculation on resize (affects columns/rows thresholds)
|
// Re-apply filters on change
|
||||||
this.handleResize = () => {
|
|
||||||
this.checkPrefetch(this.recorded);
|
|
||||||
this.checkPrefetch(this.videos);
|
|
||||||
};
|
|
||||||
try { window.addEventListener('resize', this.handleResize); } catch {}
|
|
||||||
|
|
||||||
// Re-apply filters on change (Live removed) with a tiny debounce to avoid thrashing
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const _s = this.sort();
|
this.sort();
|
||||||
const _d = this.duration();
|
this.duration();
|
||||||
const _l = this.language();
|
this.type();
|
||||||
const _dt = this.date();
|
this.language();
|
||||||
const apply = () => {
|
this.date();
|
||||||
// Reset visible window to ensure top results reflect new sort/filter instantly
|
|
||||||
this.recorded.visibleCount = Math.max(20, this.recorded.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.videos.items = this.applyFilters(this.videos.items);
|
|
||||||
// Recompute derived partitions
|
|
||||||
this.partitionVideos();
|
|
||||||
// After filtering, re-evaluate if we need to prefetch more
|
|
||||||
this.checkPrefetch(this.recorded);
|
|
||||||
this.checkPrefetch(this.videos);
|
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
try { this.cdr.detectChanges(); } catch {}
|
});
|
||||||
};
|
|
||||||
try {
|
|
||||||
clearTimeout((this as any).__fltTimer);
|
|
||||||
} catch {}
|
|
||||||
(this as any).__fltTimer = setTimeout(apply, 150);
|
|
||||||
}, { allowSignalWrites: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetAll() {
|
private resetAndLoad() {
|
||||||
for (const s of [this.recorded, this.videos]) {
|
this.version++;
|
||||||
s.items = []; s.nextCursor = null; s.loading = false; s.error = null;
|
this.allVideos.set([]);
|
||||||
s.visibleCount = s.key === 'videos' ? 12 : 20;
|
this.nextCursor.set(null);
|
||||||
}
|
this.error.set(null);
|
||||||
this.shorts = [];
|
this.loadMore();
|
||||||
this.nonShorts = [];
|
|
||||||
this.recordedNonShorts = [];
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private baseTokens(): string[] {
|
private buildQuery(): string {
|
||||||
const t = this.theme();
|
const tokens = this.themes.tokensFor(this.theme());
|
||||||
if (t === 'trending') return [];
|
|
||||||
if (t === 'live') return ['live'];
|
|
||||||
return this.themes.tokensFor(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildQueryFor(section: SectionState): string {
|
|
||||||
const tokens = [...this.baseTokens()];
|
|
||||||
if (section.key === 'recorded') {
|
|
||||||
const p = this.provider();
|
|
||||||
// Only providers that benefit from a 'replay' search hint
|
|
||||||
if (p === 'youtube' || p === 'twitch') tokens.push('replay');
|
|
||||||
// For other providers, prefer trending (empty query) to avoid bad/no results
|
|
||||||
}
|
|
||||||
return tokens.join(' ').trim();
|
return tokens.join(' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadMore() {
|
||||||
|
if (this.loading()) return;
|
||||||
|
|
||||||
|
const readiness = this.instances.getProviderReadiness(this.provider());
|
||||||
|
if (!readiness.ready) {
|
||||||
|
this.error.set(readiness.reason || 'Provider not ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
const snapshotVersion = this.version;
|
||||||
|
const query = this.buildQuery();
|
||||||
|
const provider = this.provider();
|
||||||
|
|
||||||
|
const apiCall = query
|
||||||
|
? this.api.searchVideosPage(query, this.nextCursor(), provider)
|
||||||
|
: this.api.getTrendingPage(this.nextCursor(), provider);
|
||||||
|
|
||||||
|
apiCall.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
if (snapshotVersion !== this.version) return;
|
||||||
|
|
||||||
|
const existingIds = new Set(this.allVideos().map(v => v.videoId));
|
||||||
|
const newVideos = (res.items || []).filter(v => !existingIds.has(v.videoId));
|
||||||
|
|
||||||
|
this.allVideos.update(current => [...current, ...newVideos]);
|
||||||
|
this.nextCursor.set(res.nextCursor || null);
|
||||||
|
this.loading.set(false);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
if (snapshotVersion !== this.version) return;
|
||||||
|
this.error.set(e.message || 'Failed to load videos');
|
||||||
|
this.loading.set(false);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private applyFilters(list: Video[]): Video[] {
|
private applyFilters(list: Video[]): Video[] {
|
||||||
let arr = list.slice();
|
let arr = [...list];
|
||||||
const d = this.duration();
|
const durationFilter = this.duration();
|
||||||
if (d !== 'any') {
|
if (durationFilter !== 'any') {
|
||||||
arr = arr.filter(v => {
|
arr = arr.filter(v => {
|
||||||
const dur = v.duration || 0;
|
const dur = v.duration || 0;
|
||||||
if (d === 'short') return dur > 0 && dur < 600;
|
if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min
|
||||||
if (d === 'medium') return dur >= 600 && dur < 1800;
|
if (durationFilter === 'medium') return dur >= 240 && dur < 1200; // 4-20 min
|
||||||
if (d === 'long') return dur >= 1800;
|
if (durationFilter === 'long') return dur >= 1200; // > 20 min
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language heuristic (very lightweight)
|
const typeFilter = this.type();
|
||||||
const lang = this.language();
|
if (typeFilter !== 'any') {
|
||||||
if (lang !== 'any') {
|
|
||||||
const isFrWord = (s: string) => /\b(le|la|les|des|un|une|et|ou|pour|avec|sans|sur|dans|est|cette|vidéo)\b/i.test(s);
|
|
||||||
const isEnWord = (s: string) => /\b(the|a|an|and|or|with|without|for|this|video|live)\b/i.test(s);
|
|
||||||
arr = arr.filter(v => {
|
arr = arr.filter(v => {
|
||||||
const text = `${v.title} ${v.uploaderName}`;
|
if (typeFilter === 'video') return v.type === 'video';
|
||||||
const fr = isFrWord(text) || /[éèêàùçîïô]/i.test(text);
|
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel';
|
||||||
const en = isEnWord(text);
|
if (typeFilter === 'recorded') return v.type === 'recorded';
|
||||||
return lang === 'fr' ? fr && !en : en && !fr;
|
if (typeFilter === 'short') return v.type === 'short' || v.duration < 60;
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date filter based on uploaded timestamp if available
|
const langFilter = this.language();
|
||||||
const dateOpt = this.date();
|
if (langFilter !== 'any') {
|
||||||
if (dateOpt !== 'any') {
|
// Simple heuristic, can be improved
|
||||||
|
const isFr = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
|
||||||
|
const isEn = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
|
||||||
|
arr = arr.filter(v => {
|
||||||
|
const text = `${v.title} ${v.uploaderName}`.toLowerCase();
|
||||||
|
if (langFilter === 'fr') return isFr(text);
|
||||||
|
if (langFilter === 'en') return isEn(text) && !isFr(text);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFilter = this.date();
|
||||||
|
if (dateFilter !== 'any') {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const cutoff = (() => {
|
const cutoff = {
|
||||||
if (dateOpt === 'day') return now - 24 * 3600_000;
|
day: now - 24 * 3600 * 1000,
|
||||||
if (dateOpt === 'week') return now - 7 * 24 * 3600_000;
|
week: now - 7 * 24 * 3600 * 1000,
|
||||||
if (dateOpt === 'month') return now - 30 * 24 * 3600_000;
|
month: now - 30 * 24 * 3600 * 1000,
|
||||||
if (dateOpt === 'year') return now - 365 * 24 * 3600_000;
|
year: now - 365 * 24 * 3600 * 1000,
|
||||||
return 0;
|
}[dateFilter] || 0;
|
||||||
})();
|
|
||||||
if (cutoff > 0) {
|
if (cutoff > 0) {
|
||||||
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
|
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const s = this.sort();
|
|
||||||
if (s === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
|
const sortOrder = this.sort();
|
||||||
if (s === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
|
if (sortOrder === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
|
||||||
if (s === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
|
if (sortOrder === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
|
||||||
|
if (sortOrder === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
|
||||||
|
|
||||||
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
|
this.instances.setSelectedProvider(provider);
|
||||||
try { this.instances.setSelectedProvider(provider); } catch {}
|
|
||||||
this.router.navigate(['/p', provider, 't', this.theme()]);
|
this.router.navigate(['/p', provider, 't', this.theme()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSortChange(val: any) { this.sort.set(val as any); }
|
onSortChange(val: any) { this.sort.set(val as any); }
|
||||||
onDurationChange(val: any) { this.duration.set(val as any); }
|
onDurationChange(val: any) { this.duration.set(val as any); }
|
||||||
|
onTypeChange(val: any) { this.type.set(val as any); }
|
||||||
onLanguageChange(val: any) { this.language.set(val as any); }
|
onLanguageChange(val: any) { this.language.set(val as any); }
|
||||||
onDateChange(val: any) { this.date.set(val as any); }
|
onDateChange(val: any) { this.date.set(val as any); }
|
||||||
|
|
||||||
loadInitial() {
|
retry() {
|
||||||
const readiness = this.instances.getProviderReadiness(this.provider());
|
this.error.set(null);
|
||||||
if (!readiness.ready) {
|
this.loadMore();
|
||||||
const msg = readiness.reason || 'Provider not ready';
|
|
||||||
console.warn('[ProviderTheme] Provider not ready', { provider: this.provider(), reason: msg, theme: this.theme() });
|
|
||||||
this.recorded.error = msg; this.recorded.loading = false;
|
|
||||||
this.videos.error = msg; this.videos.loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.loadMore(this.recorded);
|
|
||||||
this.loadMore(this.videos);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMore(section: SectionState) {
|
|
||||||
if (section.loading) return;
|
|
||||||
const readiness = this.instances.getProviderReadiness(this.provider());
|
|
||||||
if (!readiness.ready) {
|
|
||||||
section.error = readiness.reason || 'Provider not ready';
|
|
||||||
console.warn('[ProviderTheme] Skip load: provider not ready', { provider: this.provider(), section: section.key, reason: section.error, theme: this.theme() });
|
|
||||||
section.loading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
section.loading = true;
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
const provider = this.provider();
|
|
||||||
|
|
||||||
const query = this.buildQueryFor(section);
|
|
||||||
const snapshotVersion = this.version;
|
|
||||||
|
|
||||||
// If YouTube quota has already been flagged exceeded, surface it and avoid requests
|
|
||||||
if (provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
|
|
||||||
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
|
||||||
section.loading = false;
|
|
||||||
console.warn('[ProviderTheme] Skip load: YouTube quota exceeded', { section: section.key, theme: this.theme() });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = (res: { items: Video[]; nextCursor?: string | null }) => {
|
|
||||||
// Ignore responses from a previous state
|
|
||||||
if (snapshotVersion !== this.version) return;
|
|
||||||
// Deduplicate by videoId or URL before applying filters
|
|
||||||
const existing = new Set(section.items.map(v => v.videoId || v.url || ''));
|
|
||||||
const beforeLen = section.items.length;
|
|
||||||
const merged: Video[] = [...section.items];
|
|
||||||
const incoming = res.items || [];
|
|
||||||
const beforeKeys = existing.size;
|
|
||||||
for (const v of res.items || []) {
|
|
||||||
const key = v.videoId || v.url || '';
|
|
||||||
if (!existing.has(key)) { existing.add(key); merged.push(v); }
|
|
||||||
}
|
|
||||||
const afterKeys = existing.size;
|
|
||||||
const added = Math.max(0, afterKeys - beforeKeys);
|
|
||||||
const duplicates = Math.max(0, (incoming.length || 0) - added);
|
|
||||||
|
|
||||||
// Mettre à jour les éléments de la section
|
|
||||||
section.items = this.applyFilters(merged);
|
|
||||||
section.nextCursor = res.nextCursor || null;
|
|
||||||
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) {
|
|
||||||
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
|
|
||||||
}
|
|
||||||
if (!res.nextCursor) {
|
|
||||||
console.info('[ProviderTheme] Reached end of pagination', { provider, section: section.key, theme: this.theme(), total: section.items.length });
|
|
||||||
}
|
|
||||||
if (duplicates > 0) {
|
|
||||||
console.debug('[ProviderTheme] Deduplicated incoming items', { provider, section: section.key, duplicates, added, before: beforeLen, after: section.items.length });
|
|
||||||
}
|
|
||||||
// Post-process for YouTube: fetch durations for items lacking them to make duration filters/sorts work
|
|
||||||
if (provider === 'youtube') {
|
|
||||||
try {
|
|
||||||
const wantIds = merged.filter(v => (v.duration || 0) === 0 && !!v.videoId).slice(0, 50).map(v => v.videoId as string);
|
|
||||||
const unique = Array.from(new Set(wantIds));
|
|
||||||
if (unique.length) {
|
|
||||||
this.api.getYouTubeDurations(unique).subscribe({
|
|
||||||
next: (mapDur) => {
|
|
||||||
if (snapshotVersion !== this.version) return;
|
|
||||||
if (!mapDur) return;
|
|
||||||
// Update durations in-place
|
|
||||||
for (const v of section.items) {
|
|
||||||
const id = v.videoId || '';
|
|
||||||
if (id && mapDur[id] != null) v.duration = Number(mapDur[id]) || 0;
|
|
||||||
}
|
|
||||||
// Re-apply filters/sorts that depend on duration
|
|
||||||
section.items = this.applyFilters(section.items);
|
|
||||||
this.partitionVideos();
|
|
||||||
this.checkPrefetch(section);
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
},
|
|
||||||
error: () => {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
this.partitionVideos();
|
|
||||||
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
|
|
||||||
this.checkPrefetch(section);
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
try { this.cdr.detectChanges(); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onErr = (e?: any) => {
|
|
||||||
// Specific message for quota exceeded on YouTube
|
|
||||||
if (provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
|
|
||||||
this.api.quotaExceeded$.next(true);
|
|
||||||
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
|
|
||||||
} else if (provider === 'peertube') {
|
|
||||||
const inst = this.instances.activePeerTubeInstance();
|
|
||||||
const status = e?.status;
|
|
||||||
if (status === 404) section.error = `PeerTube (${inst}) introuvable. Essayez une autre instance.`;
|
|
||||||
else if (status === 429) section.error = `PeerTube (${inst}) limite atteinte. Réessayez plus tard.`;
|
|
||||||
else section.error = `PeerTube (${inst}) indisponible. Réessayez plus tard.`;
|
|
||||||
} else {
|
|
||||||
section.error = 'Erreur de chargement';
|
|
||||||
}
|
|
||||||
section.loading = false;
|
|
||||||
console.error('[ProviderTheme] Load error', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor, error: String(e?.message || e) });
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
try { this.cdr.detectChanges(); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (query) {
|
|
||||||
this.api.searchVideosPage(query, section.nextCursor, provider).subscribe({ next, error: onErr });
|
|
||||||
} else {
|
|
||||||
this.api.getTrendingPage(section.nextCursor, provider).subscribe({ next, error: onErr });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increase visible items by 20; fetch the next page if we need more data
|
|
||||||
showMore(section: SectionState) {
|
|
||||||
const step = section.key === 'videos' ? 12 : 20;
|
|
||||||
const target = section.visibleCount + step;
|
|
||||||
section.visibleCount = target;
|
|
||||||
|
|
||||||
// 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 });
|
|
||||||
this.loadMore(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier également si nous devons charger plus de contenu
|
|
||||||
this.checkPrefetch(section);
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
|
|
||||||
// Forcer la détection des changements pour mettre à jour l'interface utilisateur
|
|
||||||
try { this.cdr.detectChanges(); } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helpers for watch params
|
|
||||||
watchQueryParams(v: Video): Record<string, any> | null {
|
watchQueryParams(v: Video): Record<string, any> | null {
|
||||||
const p = this.provider();
|
const p = this.provider();
|
||||||
const qp: any = { p };
|
const qp: any = { p };
|
||||||
@ -495,47 +209,9 @@ export class ProviderThemePageComponent implements OnDestroy {
|
|||||||
return qp;
|
return qp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackBy to reduce DOM churn
|
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url; }
|
||||||
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url || _idx; }
|
|
||||||
|
|
||||||
// Manual retry helper
|
|
||||||
retry(section: SectionState) {
|
|
||||||
section.error = null;
|
|
||||||
this.loadMore(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Prefetch helpers ---
|
|
||||||
private handleResize: (() => void) | null = null;
|
|
||||||
|
|
||||||
private currentCols(): number {
|
|
||||||
try {
|
|
||||||
// Tailwind breakpoints: md>=768px -> 4 cols, lg>=1024px -> 6 cols, else 2 cols
|
|
||||||
if (window.matchMedia && window.matchMedia('(min-width: 1024px)').matches) return 6;
|
|
||||||
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return 4;
|
|
||||||
return 2;
|
|
||||||
} catch {
|
|
||||||
return 4; // safe default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkPrefetch(section: SectionState) {
|
|
||||||
const cols = this.currentCols();
|
|
||||||
const threshold = cols * 2; // ~2 rows
|
|
||||||
const pool = this.poolLengthFor(section);
|
|
||||||
const remaining = pool - section.visibleCount;
|
|
||||||
if (remaining <= threshold && section.nextCursor && !section.loading) {
|
|
||||||
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
|
|
||||||
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 {}
|
this.version++; // Invalidate any pending requests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,11 +44,24 @@ export class PlaylistsService {
|
|||||||
return this.http.get<Playlist[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true });
|
return this.http.get<Playlist[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public listing (no auth required)
|
||||||
|
listPublic(limit = 100, offset = 0, q?: string): Observable<Playlist[]> {
|
||||||
|
let params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
|
||||||
|
if (q && q.trim().length) params = params.set('q', q.trim());
|
||||||
|
return this.http.get<Playlist[]>(`${this.apiBase()}/playlists/public`, { params, withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
get(id: string, limit = 200, offset = 0): Observable<Playlist & { items: PlaylistItem[] }> {
|
get(id: string, limit = 200, offset = 0): Observable<Playlist & { items: PlaylistItem[] }> {
|
||||||
const params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
|
const params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
|
||||||
return this.http.get<Playlist & { items: PlaylistItem[] }>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, { params, withCredentials: true });
|
return this.http.get<Playlist & { items: PlaylistItem[] }>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, { params, withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public view (owner or public)
|
||||||
|
view(id: string, limit = 200, offset = 0): Observable<Playlist & { items: PlaylistItem[] }> {
|
||||||
|
const params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
|
||||||
|
return this.http.get<Playlist & { items: PlaylistItem[] }>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}/view`, { params, withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
create(title: string, description?: string, thumbnail?: string, isPrivate: boolean = true): Observable<Playlist> {
|
create(title: string, description?: string, thumbnail?: string, isPrivate: boolean = true): Observable<Playlist> {
|
||||||
return this.http.post<Playlist>(`${this.apiBase()}/playlists`, { title, description, thumbnail, isPrivate }, { withCredentials: true });
|
return this.http.post<Playlist>(`${this.apiBase()}/playlists`, { title, description, thumbnail, isPrivate }, { withCredentials: true });
|
||||||
}
|
}
|
||||||
|