diff --git a/README.md b/README.md index 28d0eb6..ef5abdc 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ npm run test:telemetry # télémétrie minimale * 🧩 **Thèmes** : Trending, Live, Gaming, Sports, News, Finance, Tech, Science, Health, Music, Podcasts, Movies/TV, Education, Travel, Food, DIY, Auto… * 🎯 **Shorts** : affichage dédié (séparé des vidéos longues) * ❤️ **Liked videos** avec recherche serveur +* ⏰ **À regarder plus tard** (Watch later) : bouton horloge à côté du cœur sur toutes les cartes (accueil, thèmes, recherche), liste **Vous → À regarder plus tard** (`/#/library/watch-later`), API `/api/user/watch-later` * 🕘 **History** (recherches + visionnage) * 📚 **Playlists** publiques/privées (CRUD, compteurs, dates MAJ) * 🔐 **Auth légère** (JWT), **rate-limit** API @@ -239,6 +240,7 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc. * ✅ Dist statique + API Node/Express * ✅ Playlists **publiques/privées** (tests de visibilité) * ✅ Barre de recherche **Liked videos** (filtrage serveur) +* ✅ Liste **« À regarder plus tard »** (watch later) — même stockage « tags » que les likes (aucune migration), bouton horloge à côté du cœur sur toutes les cartes, entrée « Vous » de la barre latérale, page `/#/library/watch-later` avec recherche serveur * ✅ PeerTube **multi-instances** (activer/désactiver, set active) * ✅ Thème (système / dark / light / blue / black) * ✅ Préférences (langue, thème, région, qualité par défaut) diff --git a/server/db.mjs b/server/db.mjs index 266c379..d77cf4c 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -903,8 +903,11 @@ function ensureTag(userId, name) { return id; } -export function likeVideo({ userId, provider, videoId, title, thumbnail }) { - const tagId = ensureTag(userId, 'like'); +// Les listes « par tag » (like, watch-later) partagent ces 4 fonctions : +// `tag` est le nom de ligne dans `tags` — la table sert déjà de mot-clés +// génériques, donc une nouvelle liste ne demande aucune migration. +export function likeVideo({ userId, provider, videoId, title, thumbnail, tag = 'like' }) { + const tagId = ensureTag(userId, tag); // Upsert-like behavior; ignore if exists db.prepare(`INSERT OR IGNORE INTO video_tags (user_id, provider, video_id, tag_id, created_at) VALUES (?, ?, ?, ?, ?)`) .run(userId, provider, videoId, tagId, nowIso()); @@ -922,25 +925,25 @@ export function likeVideo({ userId, provider, videoId, title, thumbnail }) { return { provider, video_id: videoId }; } -export function unlikeVideo({ userId, provider, videoId }) { - const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like'); - if (!tag) return { removed: false }; +export function unlikeVideo({ userId, provider, videoId, tag = 'like' }) { + const t = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, tag); + if (!t) return { removed: false }; const info = db.prepare(`DELETE FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`) - .run(userId, provider, videoId, tag.id); + .run(userId, provider, videoId, t.id); return { removed: (info.changes || 0) > 0 }; } -export function isVideoLiked({ userId, provider, videoId }) { - const tag = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, 'like'); - if (!tag) return false; +export function isVideoLiked({ userId, provider, videoId, tag = 'like' }) { + const t = db.prepare(`SELECT id FROM tags WHERE user_id = ? AND name = ?`).get(userId, tag); + if (!t) return false; const row = db.prepare(`SELECT 1 FROM video_tags WHERE user_id = ? AND provider = ? AND video_id = ? AND tag_id = ?`) - .get(userId, provider, videoId, tag.id); + .get(userId, provider, videoId, t.id); return Boolean(row); } -export function listLikedVideos({ userId, limit = 100, q }) { +export function listLikedVideos({ userId, limit = 100, q, tag = 'like' }) { try { - console.log(`[listLikedVideos] Récupération des vidéos aimées pour l'utilisateur ${userId}`); + console.log(`[listLikedVideos] Récupération des vidéos "${tag}" pour l'utilisateur ${userId}`); // Vérifier que la table tags existe const tableExists = db.prepare(` @@ -953,18 +956,18 @@ export function listLikedVideos({ userId, limit = 100, q }) { return []; } - // Récupérer le tag 'like' de l'utilisateur - const tag = db.prepare(` + // Récupérer le tag de la liste demandee + const tagRow = db.prepare(` SELECT id FROM tags WHERE user_id = ? AND name = ? - `).get(userId, 'like'); + `).get(userId, tag); - if (!tag) { - console.log(`[listLikedVideos] Aucun tag 'like' trouvé pour l'utilisateur ${userId}`); + if (!tagRow) { + console.log(`[listLikedVideos] Aucun tag "${tag}" trouvé pour l'utilisateur ${userId}`); return []; } - console.log(`[listLikedVideos] Tag ID: ${tag.id}`); + console.log(`[listLikedVideos] Tag ID: ${tagRow.id}`); // Vérifier que la table video_tags existe const videoTagsExists = db.prepare(` @@ -1032,11 +1035,11 @@ export function listLikedVideos({ userId, limit = 100, q }) { // La requete est devenue longue (normalisation provider x 3, sous-requetes // de repli) : la logger en entier poluait la sortie a chaque appel. - console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tag.id})`); + console.log(`[listLikedVideos] ${hasQ ? 'recherche' : 'liste'} pour ${userId} (tag ${tagRow.id})`); const rows = hasQ - ? db.prepare(query).all(userId, tag.id, like, like, like, limit) - : db.prepare(query).all(userId, tag.id, limit); + ? db.prepare(query).all(userId, tagRow.id, like, like, like, limit) + : db.prepare(query).all(userId, tagRow.id, limit); console.log(`[listLikedVideos] ${rows.length} vidéos trouvées`); return rows; diff --git a/server/index.mjs b/server/index.mjs index e293964..564f9e3 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -2713,64 +2713,63 @@ r.delete('/user/history/transcripts', authMiddleware, (req, res) => { } }); -// --- Likes --- +// --- Listes vidéo par tag : « vidéos aimées » + « à regarder plus tard » --- // NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via // cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer // seul renvoyait 401 « Unauthorized » même connecté. -r.get('/user/likes', authMiddlewareCookieAware, (req, res) => { - const limit = Math.min(500, Number(req.query.limit || 100)); - const q = typeof req.query.q === 'string' ? req.query.q : undefined; - const rows = listLikedVideos({ userId: req.user.id, limit, q }); - return res.json(rows); -}); +// `tagName` = nom de ligne dans `tags` ; c'est le SEUL paramètre qui change +// d'une liste à l'autre, les 4 routes sont donc enregistrées par une fabrique. +function registerVideoTagRoutes(prefix, tagName) { + r.get(prefix, authMiddlewareCookieAware, (req, res) => { + const limit = Math.min(500, Number(req.query.limit || 100)); + const q = typeof req.query.q === 'string' ? req.query.q : undefined; + const rows = listLikedVideos({ userId: req.user.id, limit, q, tag: tagName }); + return res.json(rows); + }); -r.post('/user/likes', authMiddlewareCookieAware, async (req, res) => { - let { provider, videoId, title, thumbnail } = req.body || {}; - try { - console.log('[POST /user/likes] payload:', { - provider, - videoId, - titlePreview: typeof title === 'string' ? title.slice(0, 80) : title, - hasThumbnail: Boolean(thumbnail) - }); - } catch {} - if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' }); + r.post(prefix, authMiddlewareCookieAware, async (req, res) => { + let { provider, videoId, title, thumbnail } = req.body || {}; + if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' }); - // Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp - try { - const needTitle = !(typeof title === 'string' && title.trim().length > 0); - const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0); - if (needTitle || needThumb) { - const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl }); - try { - const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true }); - const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {}); - if (needTitle) title = meta?.title || title || ''; - if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || ''); - } catch {} - } - } catch {} + // Server-side enrichment: if title or thumbnail is missing, fetch minimal details via yt-dlp + try { + const needTitle = !(typeof title === 'string' && title.trim().length > 0); + const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0); + if (needTitle || needThumb) { + const url = providerUrlFrom(provider, videoId, { instance: req.query.instance, slug: req.query.slug, sourceUrl: req.query.sourceUrl }); + try { + const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true }); + const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {}); + if (needTitle) title = meta?.title || title || ''; + if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || ''); + } catch {} + } + } catch {} - const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail }); - return res.status(201).json(row); -}); + const row = likeVideo({ userId: req.user.id, provider, videoId, title, thumbnail, tag: tagName }); + return res.status(201).json(row); + }); -r.delete('/user/likes', authMiddlewareCookieAware, (req, res) => { - const provider = req.query.provider ? String(req.query.provider) : ''; - const videoId = req.query.videoId ? String(req.query.videoId) : ''; - if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' }); - const result = unlikeVideo({ userId: req.user.id, provider, videoId }); - return res.json(result); -}); + r.delete(prefix, authMiddlewareCookieAware, (req, res) => { + const provider = req.query.provider ? String(req.query.provider) : ''; + const videoId = req.query.videoId ? String(req.query.videoId) : ''; + if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' }); + const result = unlikeVideo({ userId: req.user.id, provider, videoId, tag: tagName }); + return res.json(result); + }); -// Like status for a specific video -r.get('/user/likes/status', authMiddlewareCookieAware, (req, res) => { - const provider = req.query.provider ? String(req.query.provider) : ''; - const videoId = req.query.videoId ? String(req.query.videoId) : ''; - if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' }); - const liked = isVideoLiked({ userId: req.user.id, provider, videoId }); - return res.json({ liked }); -}); + // Status for a specific video + r.get(`${prefix}/status`, authMiddlewareCookieAware, (req, res) => { + const provider = req.query.provider ? String(req.query.provider) : ''; + const videoId = req.query.videoId ? String(req.query.videoId) : ''; + if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' }); + const liked = isVideoLiked({ userId: req.user.id, provider, videoId, tag: tagName }); + return res.json({ liked }); + }); +} + +registerVideoTagRoutes('/user/likes', 'like'); +registerVideoTagRoutes('/user/watch-later', 'watch-later'); // Odysee image proxy to avoid CORS/ORB issues r.get('/img/odysee', async (req, res) => { @@ -4164,6 +4163,10 @@ r.get('/openapi.json', (_req, res) => { get: { summary: 'Vidéos likées', security: bearer, responses: { 200: { description: 'likes[]' } } }, post: { summary: 'Liker', security: bearer, responses: { 201: { description: 'like' } } }, }, + '/user/watch-later': { + get: { summary: 'Liste « à regarder plus tard »', security: bearer, responses: { 200: { description: 'videos[]' } } }, + post: { summary: 'Ajouter à la liste', security: bearer, responses: { 201: { description: '{ provider, video_id }' } } }, + }, '/subscriptions': { get: { summary: 'Abonnements { items, ttl }', security: bearer, responses: { 200: { description: 'subs' } } }, post: { summary: 'S abonner (résolution chaîne)', security: bearer, responses: { 201: { description: 'sub' } } }, diff --git a/server/tests/api_coverage.test.mjs b/server/tests/api_coverage.test.mjs index d410d1b..26a6278 100644 --- a/server/tests/api_coverage.test.mjs +++ b/server/tests/api_coverage.test.mjs @@ -134,7 +134,7 @@ describe('auth + espace utilisateur', () => { assert.equal(r2.status, 401); }); it('protégées sans token -> 401', async () => { - for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/subscriptions', '/api/download/jobs']) { + for (const u of ['/api/user/me', '/api/playlists', '/api/user/likes', '/api/user/watch-later', '/api/subscriptions', '/api/download/jobs']) { const r = await J(`${base}${u}`); assert.equal(r.status, 401, u); } @@ -180,6 +180,31 @@ describe('auth + espace utilisateur', () => { const lw = await J(`${base}/api/user/history/watch`, { headers: auth(token) }); assert.equal(lw.status, 200); }); + it('watch-later : ajout, statut, liste et retrait (indépendant des likes)', async () => { + const h = { ...auth(token), 'content-type': 'application/json' }; + const add = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl1', title: 'WL', thumbnail: 'http://x/wl.jpg' }) }); + assert.equal(add.status, 201, JSON.stringify(add.body)); + const st = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) }); + assert.equal(st.status, 200); + assert.equal(st.body.liked, true, 'wl1 doit etre dans la liste watch-later'); + // La meme video ajoutee aux likes ne doit PAS apparaitre dans watch-later + const like = await J(`${base}/api/user/likes`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube', videoId: 'wl2', title: 'L', thumbnail: 'http://x/l.jpg' }) }); + assert.ok([200, 201].includes(like.status)); + const st2 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl2`, { headers: auth(token) }); + assert.equal(st2.body.liked, false, 'un like ne doit pas se retrouver dans watch-later'); + const list = await J(`${base}/api/user/watch-later?limit=500`, { headers: auth(token) }); + assert.equal(list.status, 200); + assert.ok(Array.isArray(list.body), 'watch-later repond une liste'); + assert.ok(list.body.some((r) => r.video_id === 'wl1'), 'wl1 present dans la liste'); + assert.ok(!list.body.some((r) => r.video_id === 'wl2'), 'wl2 absent de la liste watch-later'); + const rm = await J(`${base}/api/user/watch-later?provider=youtube&videoId=wl1`, { method: 'DELETE', headers: auth(token) }); + assert.equal(rm.status, 200); + assert.equal(rm.body.removed, true); + const st3 = await J(`${base}/api/user/watch-later/status?provider=youtube&videoId=wl1`, { headers: auth(token) }); + assert.equal(st3.body.liked, false, 'wl1 retire de la liste'); + const bad = await J(`${base}/api/user/watch-later`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'youtube' }) }); + assert.equal(bad.status, 400); + }); it('subscriptions + telemetry + download jobs', async () => { const h = { ...auth(token), 'content-type': 'application/json' }; const sub = await J(`${base}/api/subscriptions`, { method: 'POST', headers: h, body: JSON.stringify({ provider: 'yt', externalId: 'ch1', title: 'C' }) }); diff --git a/src/app.routes.ts b/src/app.routes.ts index d4740a2..f977d43 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -70,6 +70,12 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent), title: 'NewTube - Liked' }, + { + path: 'library/watch-later', + loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent), + data: { list: 'watch-later' }, + title: 'NewTube - Watch later' + }, { path: 'library/subscriptions', loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent), diff --git a/src/app/shared/components/video-card/video-card.component.html b/src/app/shared/components/video-card/video-card.component.html index 7c0ee25..a61e21e 100644 --- a/src/app/shared/components/video-card/video-card.component.html +++ b/src/app/shared/components/video-card/video-card.component.html @@ -42,6 +42,13 @@ CLIP CHAÎNE + +
+ + +
{{ video.durationSec | duration }} diff --git a/src/app/shared/components/video-card/video-card.component.ts b/src/app/shared/components/video-card/video-card.component.ts index 0bba5fd..34da996 100644 --- a/src/app/shared/components/video-card/video-card.component.ts +++ b/src/app/shared/components/video-card/video-card.component.ts @@ -6,6 +6,7 @@ import { DurationPipe } from '../../pipes/duration.pipe'; import { ProviderBadgeComponent } from '../provider-badge/provider-badge.component'; import { SubscribeButtonComponent } from '../subscribe-button/subscribe-button.component'; import { ChannelIdentityComponent } from '../channel-identity/channel-identity.component'; +import { LikeButtonComponent } from '../../../../components/shared/components/like-button/like-button.component'; import type { ChannelMeta, ProviderId } from '../../models/channel.model'; import { toShortProviderId } from '../../providers/provider-ids'; import { isLiveVideoItem, isShortVideoItem, isVerticalVideoItem, videoAspectRatio, verticalFrameWidthPercent } from '../../utils/video-kind'; @@ -16,7 +17,7 @@ import { capturedAgoLabel, sourceLabel } from '../../../search/adapters/provenan templateUrl: './video-card.component.html', styleUrls: ['./video-card.component.scss'], standalone: true, - imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent, ChannelIdentityComponent], + imports: [CommonModule, RouterLink, DurationPipe, ProviderBadgeComponent, SubscribeButtonComponent, ChannelIdentityComponent, LikeButtonComponent], }) export class VideoCardComponent implements OnChanges { @Input() video!: VideoItem; diff --git a/src/components/home/home.component.html b/src/components/home/home.component.html index b18d40b..8eba571 100644 --- a/src/components/home/home.component.html +++ b/src/components/home/home.component.html @@ -30,8 +30,9 @@
{{ video.duration | duration }}
-
+
+
diff --git a/src/components/library/liked/liked.component.html b/src/components/library/liked/liked.component.html index f39922e..642ab4a 100644 --- a/src/components/library/liked/liked.component.html +++ b/src/components/library/liked/liked.component.html @@ -1,9 +1,16 @@
-

- - - - Liked videos +

+ @if (isWatchLater) { + + + + } @else { + + + + } + {{ heading }}

@@ -14,7 +21,7 @@ [ngModel]="searchQuery()" (ngModelChange)="onSearchChange($event)" class="block w-full pl-10 pr-10 py-2 border border-slate-600 rounded-lg bg-slate-700 text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-150" - placeholder="Rechercher dans les vidéos aimées..." + [placeholder]="searchPlaceholder" /> @@ -34,10 +41,12 @@
{{ err }}
} @else if (items().length === 0) {
-
Aucune vidéo aimée pour le moment.
- - ⬇ Importer mes favoris (Google / Twitch) - +
{{ emptyMessage }}
+ @if (!isWatchLater) { + + ⬇ Importer mes favoris (Google / Twitch) + + }
} @else {
    diff --git a/src/components/library/liked/liked.component.ts b/src/components/library/liked/liked.component.ts index 3efbfac..404f886 100644 --- a/src/components/library/liked/liked.component.ts +++ b/src/components/library/liked/liked.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterLink } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { LikesService, LikedVideoItem } from '../../../services/likes.service'; +import { LikesService, LikedVideoItem, VideoTag } from '../../../services/likes.service'; @Component({ selector: 'app-library-liked', @@ -13,6 +13,10 @@ import { LikesService, LikedVideoItem } from '../../../services/likes.service'; }) export class LikedComponent { private likes = inject(LikesService); + private route = inject(ActivatedRoute); + + /** Liste affichée : 'like' (défaut) ou 'watch-later', portée par la route. */ + readonly list = signal('like'); loading = signal(true); error = signal(null); @@ -20,19 +24,44 @@ export class LikedComponent { searchQuery = signal(''); constructor() { - this.refresh(); + // `data` émet la valeur courante puis à chaque réactivation de route : + // le composant reste unique pour les deux listes, pas de double chargement. + this.route.data.subscribe((d) => { + this.list.set(d?.['list'] === 'watch-later' ? 'watch-later' : 'like'); + this.refresh(); + }); + } + + get isWatchLater(): boolean { + return this.list() === 'watch-later'; + } + + get heading(): string { + return this.isWatchLater ? 'À regarder plus tard' : 'Liked videos'; + } + + get searchPlaceholder(): string { + return this.isWatchLater + ? 'Rechercher dans « À regarder plus tard »...' + : 'Rechercher dans les vidéos aimées...'; + } + + get emptyMessage(): string { + return this.isWatchLater + ? 'Aucune vidéo à regarder plus tard.' + : 'Aucune vidéo aimée pour le moment.'; } refresh(q?: string): void { this.loading.set(true); this.error.set(null); - this.likes.list(200, q).subscribe({ + this.likes.list(200, q, this.list()).subscribe({ next: (rows) => { this.items.set(rows || []); this.loading.set(false); }, error: (err) => { - const msg = err?.error?.error || err?.message || 'Erreur lors du chargement des vidéos aimées'; + const msg = err?.error?.error || err?.message || 'Erreur lors du chargement de la liste'; this.error.set(String(msg)); this.loading.set(false); } diff --git a/src/components/shared/components/like-button/like-button.component.ts b/src/components/shared/components/like-button/like-button.component.ts index f45e3ef..f7281fb 100644 --- a/src/components/shared/components/like-button/like-button.component.ts +++ b/src/components/shared/components/like-button/like-button.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { LikesService } from '../../../../services/likes.service'; +import { LikesService, VideoTag } from '../../../../services/likes.service'; import { finalize } from 'rxjs'; import { AuthService } from '../../../../services/auth.service'; @@ -14,13 +14,16 @@ import { AuthService } from '../../../../services/auth.service'; @if (isAuthenticated()) { } @@ -83,6 +84,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges { private likesService = inject(LikesService); private auth = inject(AuthService); + /** Liste pilotée par ce bouton : 'like' (cœur) ou 'watch-later' (horloge). */ + @Input() list: VideoTag = 'like'; @Input() provider = 'youtube'; @Input() title?: string; @Input() thumbnail?: string; @@ -100,6 +103,27 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges { get videoId(): string { return this._videoId; } + + /** Libellé a11y (aria-label / title / sr-only) selon la liste et l'état. */ + get label(): string { + if (this.list === 'watch-later') { + return this.isLiked() + ? 'Retirer de « À regarder plus tard »' + : 'Ajouter à « À regarder plus tard »'; + } + return this.isLiked() ? 'Retirer des vidéos aimées' : 'Ajouter aux vidéos aimées'; + } + + get iconPath(): string { + if (this.list === 'watch-later') { + // Horloge (Material « schedule ») : identique dans les deux états, + // l'état se lit à la couleur (ambre quand la vidéo est enregistrée). + return 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.23.2.8-1.3-4.5-2.7z'; + } + return this.isLiked() + ? 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' + : 'M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z'; + } isLiked = signal(false); isLoading = signal(true); @@ -126,7 +150,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges { this.isLoading.set(false); return; } - if (changes['videoId'] && !changes['videoId'].firstChange) { + if ((changes['videoId'] && !changes['videoId'].firstChange) || + (changes['list'] && !changes['list'].firstChange)) { this.checkLikeStatus(); } } @@ -157,7 +182,7 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges { this.isLoading.set(true); try { - this.likesService.isLiked(this.provider, this.videoId) + this.likesService.isLiked(this.provider, this.videoId, this.list) .pipe( finalize(() => { if (!this.destroyRef) { @@ -193,7 +218,7 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges { try { if (this.isLiked()) { - this.likesService.unlike(this.provider, this.videoId) + this.likesService.unlike(this.provider, this.videoId, this.list) .pipe( finalize(() => { if (!this.destroyRef) { @@ -217,8 +242,8 @@ export class LikeButtonComponent implements OnInit, OnDestroy, OnChanges { } else { const cleanTitle = (this.title || '').trim(); const cleanThumb = (this.thumbnail || '').trim(); - try { console.debug('[LikeButton] like payload', { provider: this.provider, videoId: this.videoId, title: cleanTitle, hasThumb: !!cleanThumb }); } catch {} - this.likesService.like(this.provider, this.videoId, cleanTitle || undefined, cleanThumb || undefined) + try { console.debug('[LikeButton] tag payload', { list: this.list, provider: this.provider, videoId: this.videoId, title: cleanTitle, hasThumb: !!cleanThumb }); } catch {} + this.likesService.like(this.provider, this.videoId, cleanTitle || undefined, cleanThumb || undefined, this.list) .pipe( finalize(() => { if (!this.destroyRef) { diff --git a/src/components/sidebar/sidebar.component.html b/src/components/sidebar/sidebar.component.html index 9254bc6..7cebed4 100644 --- a/src/components/sidebar/sidebar.component.html +++ b/src/components/sidebar/sidebar.component.html @@ -64,6 +64,12 @@ {{ 'nav.liked' | t }} +
  • + + + {{ 'nav.watchLater' | t }} + +
  • diff --git a/src/components/themes/provider-theme-page.component.html b/src/components/themes/provider-theme-page.component.html index 792eb77..ba7ecf2 100644 --- a/src/components/themes/provider-theme-page.component.html +++ b/src/components/themes/provider-theme-page.component.html @@ -181,8 +181,9 @@
    {{ v.views | number }} spectateurs
    -
    +
    +
    @@ -243,7 +244,7 @@
    {{ v.duration | duration }}
    -
    +

    {{ v.title }}

    @@ -268,7 +269,7 @@
    CLIP
    {{ v.duration | duration }}
    -
    +

{{ v.title }}

diff --git a/src/components/themes/theme-page.component.html b/src/components/themes/theme-page.component.html index 6c3908d..afd9a80 100644 --- a/src/components/themes/theme-page.component.html +++ b/src/components/themes/theme-page.component.html @@ -67,8 +67,9 @@
{{ v.views | number }} spectateurs
-
+
+
diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts index 8b68dad..a94cf0f 100644 --- a/src/services/i18n.service.ts +++ b/src/services/i18n.service.ts @@ -61,6 +61,7 @@ const EN: Record = { 'nav.history': 'History', 'nav.playlists': 'Playlists', 'nav.liked': 'Liked videos', + 'nav.watchLater': 'Watch later', 'nav.downloads': 'Downloads', 'nav.import': 'Import', 'nav.explore': 'Explore', @@ -251,6 +252,7 @@ const FR: Record = { 'nav.history': 'Historique', 'nav.playlists': 'Listes de lecture', 'nav.liked': 'Vidéos que vous aimez', + 'nav.watchLater': 'À regarder plus tard', 'nav.downloads': 'Téléchargements', 'nav.import': 'Importer', 'nav.explore': 'Explorer', diff --git a/src/services/likes.service.ts b/src/services/likes.service.ts index e5fb809..d35170f 100644 --- a/src/services/likes.service.ts +++ b/src/services/likes.service.ts @@ -11,6 +11,17 @@ export interface LikedVideoItem { created_at?: string; } +/** + * Liste vidéo par tag : 'like' (vidéos aimées) ou 'watch-later' (à regarder + * plus tard). Le segment d'API suit le tag (`likes` est le nom legacy). + */ +export type VideoTag = 'like' | 'watch-later'; + +const TAG_PATH: Record = { + 'like': 'likes', + 'watch-later': 'watch-later', +}; + @Injectable({ providedIn: 'root' }) export class LikesService { private http = inject(HttpClient); @@ -24,27 +35,27 @@ export class LikesService { } } - list(limit = 100, q?: string): Observable { + list(limit = 100, q?: string, tag: VideoTag = 'like'): Observable { let params = new HttpParams().set('limit', String(limit)); if (typeof q === 'string' && q.trim().length > 0) params = params.set('q', q.trim()); - return this.http.get(`${this.apiBase()}/user/likes`, { params, withCredentials: true }); + return this.http.get(`${this.apiBase()}/user/${TAG_PATH[tag]}`, { params, withCredentials: true }); } - like(provider: string, videoId: string, title?: string, thumbnail?: string): Observable<{ provider: string; video_id: string }> { + like(provider: string, videoId: string, title?: string, thumbnail?: string, tag: VideoTag = 'like'): Observable<{ provider: string; video_id: string }> { return this.http.post<{ provider: string; video_id: string }>( - `${this.apiBase()}/user/likes`, + `${this.apiBase()}/user/${TAG_PATH[tag]}`, { provider, videoId, title, thumbnail }, { withCredentials: true } ); } - unlike(provider: string, videoId: string): Observable<{ removed: boolean }> { + unlike(provider: string, videoId: string, tag: VideoTag = 'like'): Observable<{ removed: boolean }> { const params = new HttpParams().set('provider', provider).set('videoId', videoId); - return this.http.delete<{ removed: boolean }>(`${this.apiBase()}/user/likes`, { params, withCredentials: true }); + return this.http.delete<{ removed: boolean }>(`${this.apiBase()}/user/${TAG_PATH[tag]}`, { params, withCredentials: true }); } - isLiked(provider: string, videoId: string): Observable<{ liked: boolean }> { + isLiked(provider: string, videoId: string, tag: VideoTag = 'like'): Observable<{ liked: boolean }> { const params = new HttpParams().set('provider', provider).set('videoId', videoId); - return this.http.get<{ liked: boolean }>(`${this.apiBase()}/user/likes/status`, { params, withCredentials: true }); + return this.http.get<{ liked: boolean }>(`${this.apiBase()}/user/${TAG_PATH[tag]}/status`, { params, withCredentials: true }); } }