feat(watch): refonte pro page lecture + dedup transcript

- Supprime le hint debug 'touche s' (raccourci conserve, tooltip discret)
- server/transcript.mjs: dedupeTranscriptLines collapse les auto-captions
  a fenetre glissante (12 lignes -> 3 sur l'exemple signale), applique dans
  parseJson3/parseVtt/parseXmlCaptions + filet avant cache API
- watch: layout pro (grille 1fr/360px, carte chaine+actions, labels FR,
  panneaus description/download, sidebar A suivre sticky, a11y/focus)
- transcript interactif: recherche, clic timestamp -> seek, suivi auto +
  surlignage ligne active, copie, retry
- tests: 4 nouveaux cas dedupe (23/23 pass), build prod OK
This commit is contained in:
2026-09-26 08:51:04 -04:00
parent 4bdcd393fe
commit 2db1f2deee
5 changed files with 948 additions and 335 deletions
+69 -10
View File
@@ -16,7 +16,7 @@ import * as cheerio from 'cheerio';
import axios from 'axios';
import rumbleRouter from './rumble.mjs';
import { providerRegistry, validateProviders } from './providers/registry.mjs';
import { pickTrack, parseTrackText, parseVtt, orderedTracks, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs';
import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs';
import {
getUserByUsername,
getUserById,
@@ -80,6 +80,30 @@ import { fetchChannelContent } from './providers/channel-content.mjs';
import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs';
import { ytScrapeCacheStats } from './providers/youtube.mjs';
/**
* Options réseau communes pour les appels yt-dlp : cookies YouTube exportés
* (session résidentielle de confiance) + proxy sortant. Sans eux, les IPs
* de datacenter se voient servir du vide/429 sur timedtext et parfois sur
* les dumps. Absents par défaut -> comportement inchangé.
*/
function ytdlpNetOpts() {
const opts = {};
try {
const cookies = String(process.env.YT_COOKIES_FILE || '').trim();
if (cookies) {
try {
if (fs.existsSync(cookies)) opts.cookies = cookies;
else console.warn(`[transcript] YT_COOKIES_FILE introuvable : ${cookies}`);
} catch {}
}
} catch {}
try {
const proxy = String(process.env.YT_EGRESS_PROXY || '').trim();
if (proxy) opts.proxy = proxy;
} catch {}
return opts;
}
const app = express();
const PORT = Number(process.env.PORT || 4000);
// yt-dlp: prefer the newest binary available. The copy bundled with
@@ -2066,7 +2090,9 @@ app.get('/api/trending', async (req, res) => {
// Alias to support Angular dev proxy paths in both dev and production builds
app.use('/proxy/api', r);
// Mount dedicated Rumble router (browse, search, video)
// Idem transcript : exposé sous /api ET /proxy/api (fallback du front Watch).
app.use('/api/rumble', rumbleRouter);
app.use('/proxy/api/rumble', rumbleRouter);
// -------------------- Client config from environment --------------------
// WARNING: Values served here are exposed to the browser.
@@ -2409,7 +2435,7 @@ const TRANSCRIPT_UNSUPPORTED_PROVIDERS = new Set(['twitch', 'odysee', 'rumble'])
* parsed lines with the language that worked, or null. Bounded by a timeout
* (yt-dlp subtitle downloads can hang on .part files); partial results on
* disk are still scanned when yt-dlp exits non-zero. */
async function transcriptViaYtDlp(url, langs) {
async function transcriptViaYtDlp(url, langs, netOpts = {}) {
const osMod = await import('node:os');
const dir = await fs.promises.mkdtemp(path.join(osMod.tmpdir(), 'newtube-transcript-'));
const scanDir = async (wanted) => {
@@ -2457,6 +2483,7 @@ async function transcriptViaYtDlp(url, langs) {
noWarnings: true,
noCheckCertificates: true,
noPlaylist: true,
...netOpts,
output: path.join(dir, '%(id)s'),
});
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 90000);
@@ -2496,7 +2523,11 @@ async function fetchTimedTextLines(track) {
return parseTrackText(text, transcriptTrackExt(track));
}
app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
// NOTE: montée sur le router `r` (et non `app`) pour être servie sous les
// DEUX préfixes `/api` et `/proxy/api` (cf. apiBase() côté front + rewrite
// du proxy dev). Une route `app.get('/api/...')` ici répondrait index.html
// (fallback SPA) sous `/proxy/api/...` en prod.
r.get('/transcript/:provider/:videoId', transcriptLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
const lang = String(req.query.lang || 'fr').slice(0, 12) || 'fr';
@@ -2541,7 +2572,7 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
}
if (!meta) {
try {
const raw = await youtubedl(transcriptUrl, { dumpSingleJson: true, skipDownload: true, noWarnings: true, noCheckCertificates: true });
const raw = await youtubedl(transcriptUrl, { dumpSingleJson: true, skipDownload: true, noWarnings: true, noCheckCertificates: true, ...ytdlpNetOpts() });
meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
} catch (e) {
console.error('[transcript] yt-dlp failed:', e?.message || e);
@@ -2561,11 +2592,19 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
// then everything else. Translated tracks are often rate-limited while the
// original still works — never fail on the first track alone.
// Bounded: hammering dozens of timedtext URLs only worsens YouTube 429s.
// YouTube-only last resort: server-side auto-translation (`&tlang=`) of a
// fetchable track when the requested language's own track stays walled.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let lines = [];
let workedLang = chosenLang;
let sawRateLimit = false;
for (const cand of orderedTracks(meta, lang).slice(0, 5)) {
const baseCands = normalized === 'youtube'
? firstPerLanguage(orderedTracks(meta, lang), 10)
: orderedTracks(meta, lang).slice(0, 5);
const candidates = normalized === 'youtube'
? baseCands.concat(translatedFallbacks(baseCands, lang)).slice(0, 12)
: baseCands;
for (const cand of candidates) {
let attempt = 0;
for (;;) {
try {
@@ -2595,7 +2634,7 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
// Direct timedtext fetches failed (429 / Sorry pages / impersonation):
// let yt-dlp download the subtitles instead (handles all of the above).
try {
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(Boolean));
const viaDlp = await transcriptViaYtDlp(transcriptUrl, [lang, chosenLang].filter(Boolean), ytdlpNetOpts());
if (viaDlp && viaDlp.lines && viaDlp.lines.length) {
lines = viaDlp.lines;
if (viaDlp.lang) workedLang = viaDlp.lang;
@@ -2616,6 +2655,7 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
rateLimited: sawRateLimit || undefined,
});
}
lines = dedupeTranscriptLines(lines);
const result = { lang: workedLang, available: true, languages: languages || [], lines };
transcriptCacheSet(cacheKey, result);
return res.json(result);
@@ -2629,15 +2669,34 @@ app.get('/api/transcript/:provider/:videoId', transcriptLimiter, async (req, res
const distRoot = path.join(process.cwd(), 'dist');
const distBrowser = path.join(distRoot, 'browser');
const staticDir = fs.existsSync(distBrowser) ? distBrowser : distRoot;
// Mount static files unconditionally; if path missing, it will just not serve anything
app.use(express.static(staticDir, { maxAge: '1h', index: 'index.html' }));
// SPA fallback: any non-API GET should serve index.html
// Mount static files unconditionally; if path missing, it will just not serve anything.
// Hashed bundles (.js/.css) sont immuables -> cache long ; index.html ne doit
// JAMAIS être caché (sinon le navigateur rejoue d'anciens chunks après un
// redéploiement et appelle l'API avec d'anciens formats d'URL).
app.use(express.static(staticDir, {
maxAge: '1h',
index: 'index.html',
setHeaders: (res, filePath) => {
try {
if (String(filePath || '').toLowerCase().endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.removeHeader('Expires');
}
} catch {}
},
}));
// SPA fallback: any non-API GET should serve index.html (toujours frais, cf. ci-dessus)
app.get('*', (req, res, next) => {
try {
const url = req.originalUrl || req.url || '';
if (url.startsWith('/api/')) return next();
const indexPath = path.join(staticDir, 'index.html');
if (fs.existsSync(indexPath)) return res.sendFile(indexPath);
if (fs.existsSync(indexPath)) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
return res.sendFile(indexPath);
}
return next();
} catch {
return next();
+81
View File
@@ -13,7 +13,10 @@ import {
parseVtt,
parseTrackText,
parseXmlCaptions,
dedupeTranscriptLines,
orderedTracks,
translatedFallbacks,
firstPerLanguage,
looksLikeHtmlError,
capLines,
normalizeTranscriptProvider,
@@ -151,6 +154,84 @@ describe('orderedTracks + parseTrackText + XML captions', () => {
const v = parseTrackText('WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nHello\n', 'vtt');
assert.equal(v.length, 1);
});
it('translatedFallbacks appends &tlang= candidates from other languages only', () => { const cands = [
{ track: { url: 'https://x/t?lang=en-US', ext: 'json3' }, lang: 'en-us' },
{ track: { url: 'https://x/t?lang=fr', ext: 'json3' }, lang: 'fr' },
];
const t = translatedFallbacks(cands, 'en');
assert.equal(t.length, 1);
assert.equal(t[0].lang, 'en');
assert.match(t[0].track.url, /tlang=en/);
// Same primary language sources are skipped, not re-translated.
assert.equal(translatedFallbacks(cands, 'fr').length, 1);
assert.equal(translatedFallbacks(cands, '').length, 0);
});
it('firstPerLanguage keeps one candidate per language in order, bounded', () => {
const cands = [
{ track: { url: 'https://x/a' }, lang: 'en-US' },
{ track: { url: 'https://x/b' }, lang: 'en' },
{ track: { url: 'https://x/c' }, lang: 'fr' },
{ track: { url: 'https://x/d' }, lang: null },
];
const out = firstPerLanguage(cands, 10).map((c) => c.track.url);
assert.deepEqual(out, ['https://x/a', 'https://x/c', 'https://x/d']);
assert.equal(firstPerLanguage(cands, 2).length, 2);
});
});
describe('dedupeTranscriptLines (rolling-window auto-captions)', () => {
it('collapses prefix extensions and exact duplicates', () => {
const lines = dedupeTranscriptLines([
{ t: 0, dur: 1, text: 'Tac.' },
{ t: 1, dur: 1, text: 'Tac.' },
{ t: 1, dur: 2, text: 'Tac. David Prou est avec nous. David,' },
{ t: 2, dur: 1, text: 'David Prou est avec nous. David,' },
{ t: 2, dur: 2, text: 'David Prou est avec nous. David, bonjour.' },
{ t: 3, dur: 1, text: 'bonjour.' },
{ t: 3, dur: 2, text: 'bonjour. Bonjour Benois.' },
]);
assert.ok(lines.length < 7, `expected fewer lines, got ${lines.length}`);
assert.ok(lines.length >= 1);
const joined = lines.map((l) => l.text).join(' ');
assert.match(joined, /David Prou/);
assert.match(joined, /Benois/);
// No word lost, no exact consecutive duplicates remain.
for (let i = 1; i < lines.length; i++) {
assert.notEqual(lines[i].text.trim().toLowerCase(), lines[i - 1].text.trim().toLowerCase());
}
});
it('merges sliding partial overlaps without fragments', () => {
const lines = dedupeTranscriptLines([
{ t: 6, dur: 2, text: "Alors ce roman québécois c'était ça ou" },
{ t: 6, dur: 2, text: "Alors ce roman québécois c'était ça ou mourir ?" },
{ t: 7, dur: 1, text: 'mourir ?' },
]);
assert.equal(lines.length, 1);
assert.match(lines[0].text, /mourir/);
});
it('parseJson3 dedupes rolling events end-to-end', () => {
const lines = parseJson3({
events: [
{ tStartMs: 0, dDurationMs: 1000, segs: [{ utf8: 'Tac.' }] },
{ tStartMs: 1000, dDurationMs: 1000, segs: [{ utf8: 'Tac.' }] },
{ tStartMs: 1000, dDurationMs: 2000, segs: [{ utf8: 'Tac. David Prou est avec nous.' }] },
{ tStartMs: 2000, dDurationMs: 1000, segs: [{ utf8: 'David Prou est avec nous.' }] },
],
});
assert.equal(lines.length, 1);
assert.match(lines[0].text, /David Prou/);
});
it('keeps distinct sentences on separate cues', () => {
const lines = dedupeTranscriptLines([
{ t: 0, dur: 2, text: 'Bonjour à tous.' },
{ t: 10, dur: 2, text: 'On parle intelligence artificielle.' },
{ t: 20, dur: 2, text: 'Merci beaucoup.' },
]);
assert.equal(lines.length, 3);
});
});
describe('capLines + normalizeTranscriptProvider', () => { it('truncates very long transcripts', () => {
+152 -3
View File
@@ -123,6 +123,103 @@ export function orderedTracks(json, lang = 'fr') {
return out;
}
/**
* Collapse YouTube rolling-window auto-captions into readable cues.
*
* Auto-generated tracks repeat a sliding window on every event:
* `Tac.` -> `Tac. David...` -> `David...` -> `David... bonjour.`
* Without dedup the UI shows each partial 2-3x (see issue example).
*
* Strategy (order-preserving, lossless on words):
* - drop exact consecutive duplicates (extend duration instead),
* - if the current cue starts with / contains the previous one (or vice
* versa), keep the longest and extend its duration,
* - else strip the longest suffix->prefix word overlap and merge the
* remainder into the previous cue when timestamps are close;
* long cues are split to keep every line readable (<= ~220 chars).
* @param {TranscriptLine[]} lines raw parsed cues
* @returns {TranscriptLine[]}
*/
export function dedupeTranscriptLines(lines) {
const list = Array.isArray(lines) ? lines : [];
if (list.length < 2) return list.slice();
const MAX_CHARS = 220;
const MAX_GAP = 3.0;
const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
const norm = (s) => clean(s).toLowerCase();
const endOf = (l) => Number(l?.t || 0) + Math.max(0, Number(l?.dur || 0));
/** Longest overlap (in words) where suffix(prev) == prefix(curr). */
const wordOverlap = (prevTokens, currTokens) => {
const n = Math.min(prevTokens.length, currTokens.length);
// Don't consume the whole current cue as "overlap".
for (let k = n - 1; k >= 1; k--) {
let ok = true;
for (let i = 0; i < k; i++) {
if (prevTokens[prevTokens.length - k + i] !== currTokens[i]) { ok = false; break; }
}
if (ok) return k;
}
return 0;
};
const out = [];
for (const raw of list) {
const text = clean(raw?.text);
if (!text) continue;
let t = Number(raw?.t || 0);
if (!Number.isFinite(t) || t < 0) t = 0;
let dur = Number(raw?.dur || 0);
if (!Number.isFinite(dur) || dur < 0) dur = 0;
const cur = { t, dur, text };
const prev = out[out.length - 1];
if (!prev) { out.push(cur); continue; }
const pn = norm(prev.text);
const cn = norm(cur.text);
if (!pn || !cn) continue;
// 1. Exact consecutive duplicate -> extend, skip.
if (pn === cn) {
prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t));
continue;
}
// 2. Inclusion / prefix extension (rolling window) -> keep longest.
if (cn.startsWith(pn)) {
prev.text = cur.text;
prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t));
continue;
}
if (pn.includes(cn) && cur.t - prev.t < MAX_GAP) {
prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t));
continue;
}
// 3. Partial sliding overlap -> merge remainder into previous cue.
const prevTokens = pn.split(' ').filter(Boolean);
const currTokens = cn.split(' ').filter(Boolean);
const k = wordOverlap(prevTokens, currTokens);
if (k >= 1 && cur.t - prev.t <= MAX_GAP) {
const overlapChars = prevTokens.slice(prevTokens.length - k).join(' ').length;
if (overlapChars >= 3) {
const remainder = clean(cur.text.split(/\s+/).slice(k).join(' '));
if (!remainder) {
prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t));
continue;
}
if (prev.text.length + remainder.length + 1 <= MAX_CHARS) {
prev.text = `${prev.text} ${remainder}`.trim();
prev.dur = Math.max(prev.dur, Math.max(0, endOf(cur) - prev.t));
continue;
}
// Previous cue already full: emit the new content as its own cue.
out.push({ t: cur.t, dur: cur.dur, text: remainder });
continue;
}
}
out.push(cur);
}
return out;
}
/**
* Parse a YouTube `json3` timedtext payload into normalized lines.
* @param {any} data parsed JSON
@@ -142,7 +239,7 @@ export function parseJson3(data) {
text,
});
}
return capLines(out);
return capLines(dedupeTranscriptLines(out));
}
/** Decode a handful of HTML entities found in VTT payloads. */
@@ -213,7 +310,7 @@ export function parseXmlCaptions(text) {
out.push({ t: start, dur: dur > 0 ? dur : 0, text: cleaned });
}
}
return capLines(out);
return capLines(dedupeTranscriptLines(out));
}
/** Detect YouTube "Sorry / automated queries" style HTML error pages. */
@@ -257,7 +354,7 @@ export function parseVtt(text) {
if (!decoded || /^WEBVTT/i.test(decoded)) continue;
out.push({ t: start, dur: Math.max(0, end - start), text: decoded });
}
return capLines(out);
return capLines(dedupeTranscriptLines(out));
}
/** Bound response volume for very long transcripts (Phase 1 decision: truncate). */
@@ -292,6 +389,58 @@ export function parseTrackText(text, ext = '') {
return parseXmlCaptions(text);
}
/**
* Build auto-translated fallbacks (`&tlang=xx`) from source candidates.
* Used as LAST resort when no source track in the requested language is
* fetchable but another language is (e.g. en-US walled while fr answers):
* YouTube translates server-side, so `fr_url + &tlang=en` yields English
* lines. Bounded to 2 extra candidates. YouTube-only (timedtext URLs).
* @param {Array<{ track: any, lang: string|null }>} candidates
* @param {string} targetLang requested language
* @returns {Array<{ track: any, lang: string|null }>}
*/
export function translatedFallbacks(candidates, targetLang) {
const primary = String(targetLang || '').toLowerCase().split('-')[0];
if (!primary) return [];
const seen = new Set((candidates || []).map((c) => String(c?.track?.url || '')));
const out = [];
for (const c of candidates || []) {
const srcPrimary = String(c?.lang || '').toLowerCase().split('-')[0];
if (!srcPrimary || srcPrimary === primary) continue;
const base = String(c?.track?.url || '');
if (!base) continue;
const url = `${base}${base.includes('?') ? '&' : '?'}tlang=${encodeURIComponent(primary)}`;
if (seen.has(url)) continue;
seen.add(url);
out.push({ track: { ...c.track, url, name: `${c.track.name || srcPrimary} (auto-translated)` }, lang: primary });
if (out.length >= 2) break;
}
return out;
}
/**
* Keep the first candidate of each primary language (`en-US`+`en` -> one),
* preserving order. Guarantees every available language is tried at least
* once when the caller wants "any language" instead of failing after an
* arbitrary slice. Bounded by `max` (default 10).
* @param {Array<{ track: any, lang: string|null }>} candidates
* @param {number} [max]
* @returns {Array<{ track: any, lang: string|null }>}
*/
export function firstPerLanguage(candidates, max = 10) {
const n = Math.max(1, Number(max || 10));
const seen = new Set();
const out = [];
for (const c of candidates || []) {
const p = String(c?.lang || '').toLowerCase().split('-')[0] || 'und';
if (seen.has(p)) continue;
seen.add(p);
out.push(c);
if (out.length >= n) break;
}
return out;
}
/** Map short (`yt`) and long (`youtube`) provider ids to `providerUrlFrom()` names. */
export function normalizeTranscriptProvider(provider) {
const p = String(provider || '').trim().toLowerCase();
+453 -315
View File
@@ -1,100 +1,115 @@
<div class="container mx-auto p-4 lg:p-6 lg:max-w-7xl">
<div class="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8 lg:py-6">
@if (loading()) {
<div class="animate-pulse flex flex-col lg:flex-row gap-6">
<div class="lg:w-2/3">
<div class="aspect-video bg-slate-700 rounded-lg"></div>
<div class="animate-pulse grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div>
<div class="aspect-video rounded-2xl bg-slate-800"></div>
<div class="mt-4 space-y-3">
<div class="h-6 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
<div class="h-6 w-3/4 rounded bg-slate-800"></div>
<div class="h-4 w-1/2 rounded bg-slate-800"></div>
<div class="flex items-center gap-3 pt-2">
<div class="h-10 w-10 rounded-full bg-slate-800"></div>
<div class="h-4 w-40 rounded bg-slate-800"></div>
</div>
</div>
</div>
<div class="lg:w-1/3 space-y-4">
@for (item of [1,2,3,4]; track item) {
<div class="flex items-start space-x-3">
<div class="w-40 h-24 bg-slate-700 rounded"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-slate-700 rounded"></div>
<div class="h-4 bg-slate-700 rounded w-2/3"></div>
<div class="space-y-4">
@for (item of [1,2,3,4,5]; track item) {
<div class="flex items-start gap-3">
<div class="h-24 w-40 shrink-0 rounded-xl bg-slate-800"></div>
<div class="flex-1 space-y-2 pt-1">
<div class="h-4 rounded bg-slate-800"></div>
<div class="h-4 w-2/3 rounded bg-slate-800"></div>
</div>
</div>
}
</div>
</div>
} @else if (video(); as v) {
<div class="flex flex-col lg:flex-row gap-6">
<!-- Main Content: Player and Details -->
<div class="lg:w-2/3">
<div class="aspect-video rounded-lg overflow-hidden shadow-2xl bg-black">
@if (embedUrl(); as src) {
<iframe #embedFrame class="w-full h-full" [src]="src" frameborder="0"
referrerpolicy="origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen></iframe>
} @else {
<app-video-player
[videoSource]="videoSource()"
[startPositionSeconds]="startPositionSeconds()"
[watchHistoryId]="watchHistoryId()"
></app-video-player>
}
</div>
<div class="mt-4">
@if (availableQualities().length > 0) {
<div class="flex items-center gap-3 text-sm text-slate-300 mb-3">
<label class="text-slate-400">Quality</label>
<select #qsel class="bg-slate-800 text-slate-200 rounded px-2 py-1"
(change)="onSelectQuality(qsel.value)">
<option value="">Auto</option>
@for (q of availableQualities(); track q) {
<option [value]="q">{{ q }}</option>
}
</select>
</div>
}
<h1 class="text-2xl font-bold text-white">{{ v.title }}</h1>
<div class="text-sm text-slate-400 mt-1">
{{ formatViewsFr(v.views) }} • {{ formatDateAbsFr(v.uploadedDate) }}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px] xl:gap-8">
<!-- ============ Colonne principale ============ -->
<div class="min-w-0">
<!-- Lecteur -->
<section aria-label="Lecteur vidéo" class="overflow-hidden rounded-2xl bg-black shadow-2xl ring-1 ring-white/10">
<div class="aspect-video">
@if (embedUrl(); as src) {
<iframe #embedFrame class="h-full w-full" [src]="src" frameborder="0"
referrerpolicy="origin"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen title="Lecteur vidéo"></iframe>
} @else {
<app-video-player
[videoSource]="videoSource()"
[startPositionSeconds]="startPositionSeconds()"
[watchHistoryId]="watchHistoryId()"
></app-video-player>
}
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mt-3 text-slate-400 gap-3">
<div class="flex items-center space-x-3">
<a *ngIf="canSubscribe(); else plainIdentity"
[routerLink]="['/channel', subscribeProvider(), subscribeExternalId()]"
class="flex items-center space-x-3 rounded-lg"
title="Ouvrir la page de la chaîne">
</section>
@if (availableQualities().length > 0) {
<div class="mt-3 flex items-center gap-2 text-sm text-slate-400">
<label for="qualitySel" class="font-medium">Qualité</label>
<select id="qualitySel" #qsel class="rounded-lg border border-slate-700 bg-slate-800 px-2 py-1 text-sm text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
(change)="onSelectQuality(qsel.value)">
<option value="">Auto</option>
@for (q of availableQualities(); track q) {
<option [value]="q">{{ q }}</option>
}
</select>
</div>
}
<!-- Titre + métadonnées -->
<section class="mt-4" aria-label="Informations vidéo">
<h1 class="text-xl font-bold leading-snug text-white sm:text-2xl">{{ v.title || 'Vidéo sans titre' }}</h1>
<p class="mt-1.5 text-sm text-slate-400">
{{ formatViewsFr(v.views) }} <span aria-hidden="true" class="mx-1">•</span> {{ formatDateAbsFr(v.uploadedDate) }}
@if (v.duration > 0) {
<span aria-hidden="true" class="mx-1">•</span> {{ v.duration | duration }}
}
</p>
</section>
<!-- Chaîne + actions -->
<section class="mt-4 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-3 sm:p-4" aria-label="Chaîne et actions">
<div class="flex flex-wrap items-center gap-3">
<a *ngIf="canSubscribe(); else plainIdentity"
[routerLink]="['/channel', subscribeProvider(), subscribeExternalId()]"
class="flex min-w-0 flex-1 items-center gap-3 rounded-xl p-1 transition hover:bg-slate-700/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
title="Ouvrir la page de la chaîne">
@if (channelAvatarUrl(); as avatar) {
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
class="h-11 w-11 shrink-0 rounded-full bg-slate-700 object-cover" loading="lazy" referrerpolicy="no-referrer">
} @else {
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-slate-700 text-lg font-bold text-slate-200"
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
}
<div class="min-w-0">
<div class="truncate font-semibold text-slate-100">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div class="text-sm text-slate-400">{{ subs }} abonnés</div>
}
</div>
</a>
<ng-template #plainIdentity>
<div class="flex min-w-0 flex-1 items-center gap-3">
@if (channelAvatarUrl(); as avatar) {
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
class="w-10 h-10 rounded-full object-cover bg-slate-700 hover:ring-2 hover:ring-red-500/60" loading="lazy" referrerpolicy="no-referrer">
class="h-11 w-11 shrink-0 rounded-full bg-slate-700 object-cover" loading="lazy" referrerpolicy="no-referrer">
} @else {
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-slate-200 font-bold hover:ring-2 hover:ring-red-500/60"
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-slate-700 text-lg font-bold text-slate-200"
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
}
<div>
<div class="font-semibold text-slate-200 hover:text-red-300">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
<div class="min-w-0">
<div class="truncate font-semibold text-slate-100">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div>{{ subs }} abonnés</div>
<div class="text-sm text-slate-400">{{ subs }} abonnés</div>
}
</div>
</a>
<ng-template #plainIdentity>
@if (channelAvatarUrl(); as avatar) {
<img [src]="avatar" [alt]="v.uploaderName" (error)="onAvatarError()"
class="w-10 h-10 rounded-full object-cover bg-slate-700" loading="lazy" referrerpolicy="no-referrer">
} @else {
<div class="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center text-slate-200 font-bold"
[attr.aria-label]="v.uploaderName">{{ channelInitial() }}</div>
}
<div>
<div class="font-semibold text-slate-200">{{ v.uploaderName || channelMeta()?.title || 'Chaîne inconnue' }}</div>
@if (channelSubsLabel(); as subs) {
<div>{{ subs }} abonnés</div>
}
</div>
</ng-template>
@if (canSubscribe() && isLoggedIn()) {
</div>
</ng-template>
@if (canSubscribe() && isLoggedIn()) {
<span title="Raccourci clavier : S">
<app-subscribe-button
[provider]="subscribeProvider()!"
[externalId]="subscribeExternalId()!"
@@ -102,276 +117,399 @@
(subscribed)="onSubscribedFromWatch($event)"
(unsubscribed)="onUnsubscribedFromWatch()">
</app-subscribe-button>
} @else if (canSubscribe() && !isLoggedIn()) {
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner"
class="inline-flex items-center gap-2 rounded-md border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200">
S'abonner
</a>
}
</div>
@if (subscribeError(); as err) {
<div class="text-sm text-red-300">{{ err }}</div>
}
@if (subServiceError(); as serr) {
<div class="text-sm text-red-300">{{ serr }}</div>
}
@if (canSubscribe() && isLoggedIn()) {
<div class="text-xs text-slate-500" title="Raccourci clavier">Astuce : touche « s » pour (dé)s'abonner</div>
}
<div class="flex items-center space-x-4 mt-3 sm:mt-0">
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition"
[class.pointer-events-none]="!openOnProviderUrl()" [class.opacity-60]="!openOnProviderUrl()"
title="Ouvrir sur le site du fournisseur">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 3a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 11-2 0V5.414l-7.293 7.293a1 1 0 01-1.414-1.414L14.586 4H11a1 1 0 01-1-1z"/>
<path d="M5 4a1 1 0 011-1h3a1 1 0 110 2H7v8h8v-2a1 1 0 112 0v3a1 1 0 01-1 1H6a1 1 0 01-1-1V4z"/>
</svg>
<span class="hidden sm:inline"></span>
</span>
} @else if (canSubscribe() && !isLoggedIn()) {
<a routerLink="/auth/login" title="Connectez-vous pour vous abonner (raccourci : S une fois connecté)"
class="inline-flex items-center gap-2 rounded-lg border border-slate-600 bg-slate-800/70 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
S'abonner
</a>
<button class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
<span>{{ formatNumber(v.likes) }}</span>
</button>
@if (isLoggedIn()) {
<button (click)="toggleLike()"
[disabled]="likeBusy()"
[class.bg-red-600]="liked()"
[class.hover\:bg-red-500]="liked()"
title="{{ liked() ? 'Liked' : 'Like' }}"
class="flex items-center justify-center bg-slate-700 hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed p-2 rounded-full transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0112 5.414 6 6 0 0121.193 14.275C18.716 16.752 12 21 12 21z" />
</svg>
</button>
}
@if (isLoggedIn()) {
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
}
<button (click)="copyLink()" title="Copy link"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-full transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1a3 3 0 013 3v10a3 3 0 01-3 3H6a3 3 0 01-3-3V4a3 3 0 013-3h10zm0 2H6a1 1 0 00-1 1v10a1 1 0 001 1h10a1 1 0 001-1V4a1 1 0 00-1-1z"/>
<path d="M18 7h2a3 3 0 013 3v10a3 3 0 01-3 3H10a3 3 0 01-3-3v-2h2v2a1 1 0 001 1h10a1 1 0 001-1V10a1 1 0 00-1-1h-2V7z"/>
</svg>
<span class="hidden sm:inline"></span>
</button>
@if (isLoggedIn()) {
<button (click)="openDownloadPanel()"
class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<!-- Arrow down -->
<path d="M10 3a1 1 0 011 1v7l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 11V4a1 1 0 011-1z" />
<!-- Tray -->
<path d="M4 17a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
</svg>
<span></span>
</button>
}
</div>
</div>
<div class="mt-4 bg-slate-800/70 p-4 rounded-lg">
<p class="whitespace-pre-wrap text-slate-300" [class.line-clamp-3]="!showFullDescription()">
{{ hasDescription() ? descriptionText() : 'Aucune description disponible.' }}
</p>
@if (showMoreAvailable()) {
<button (click)="toggleDescription()" class="text-red-400 hover:text-red-300 font-semibold mt-2">
{{ showFullDescription() ? 'Show less' : 'Show more' }}
</button>
}
</div>
@if (subscribeError(); as err) {
<div class="mt-2 text-sm text-red-300" role="alert">{{ err }}</div>
}
@if (subServiceError(); as serr) {
<div class="mt-2 text-sm text-red-300" role="alert">{{ serr }}</div>
}
<!-- Download Panel (shown only when logged in) -->
@if (isLoggedIn() && downloadOpen()) {
<div class="mt-4 bg-slate-800/70 p-4 rounded-lg border border-slate-700">
<div class="flex items-start gap-4 flex-wrap">
<div class="flex-1 min-w-[240px]">
<label class="block text-sm text-slate-400 mb-1">Format / Qualité</label>
@if (formatsLoading()) {
<div class="text-slate-400 text-sm">Chargement des formats...</div>
} @else if (downloadFormats().length > 0) {
<select #fmtSel class="w-full bg-slate-900 text-slate-200 border border-slate-700 rounded px-2 py-2"
(change)="selectedFormatId.set(fmtSel.value || null)">
<option value="">Auto (best)</option>
@for (f of downloadFormats(); track f.id) {
<option [value]="f.id">{{ f.label }}</option>
}
</select>
} @else if (downloadError()) {
<div class="text-red-300 text-sm">{{ downloadError() }}</div>
} @else {
<div class="text-slate-400 text-sm">Aucun format disponible.</div>
}
<!-- Barre d'actions -->
<div class="mt-3 flex flex-wrap items-center gap-2 border-t border-slate-700/60 pt-3" role="toolbar" aria-label="Actions vidéo">
<span class="inline-flex items-center gap-1.5 rounded-full bg-slate-700/70 px-3 py-1.5 text-sm text-slate-200" title="Mentions J'aime">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.787l.09.044a2 2 0 002.253-.334l.09-.092l3.535-3.536a2 2 0 00.586-1.414V5a2 2 0 00-2-2H9a2 2 0 00-2 2v5.333z" /></svg>
<span class="tabular-nums">{{ formatNumber(v.likes) }}</span>
</span>
@if (isLoggedIn()) {
<button (click)="toggleLike()" [disabled]="likeBusy()"
[attr.aria-pressed]="liked()" [title]="liked() ? 'Ne plus aimer' : 'J\'aime'"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:opacity-60"
[class.bg-red-600]="liked()" [class.text-white]="liked()"
[class.bg-slate-700]="!liked()" [class.text-slate-200]="!liked()" [class.hover:bg-slate-600]="!liked()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" [attr.fill]="liked() ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 21s-6.716-4.248-9.193-6.725A6 6 0 0112 5.414 6 6 0 0121.193 14.275C18.716 16.752 12 21 12 21z" />
</svg>
<span>{{ liked() ? 'Aimée' : 'J\'aime' }}</span>
</button>
}
@if (isLoggedIn()) {
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
}
<button (click)="copyLink()" title="Copier le lien de la vidéo"
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M16 1a3 3 0 013 3v10a3 3 0 01-3 3H6a3 3 0 01-3-3V4a3 3 0 013-3h10zm0 2H6a1 1 0 00-1 1v10a1 1 0 001 1h10a1 1 0 001-1V4a1 1 0 00-1-1z"/>
<path d="M18 7h2a3 3 0 013 3v10a3 3 0 01-3 3H10a3 3 0 01-3-3v-2h2v2a1 1 0 001 1h10a1 1 0 001-1V10a1 1 0 00-1-1h-2V7z"/>
</svg>
<span>{{ linkCopied() ? 'Lien copié !' : 'Partager' }}</span>
</button>
<button (click)="toggleTranscript()" [attr.aria-expanded]="transcriptOpen()" aria-controls="transcript-panel"
[title]="transcriptOpen() ? 'Masquer la transcription' : 'Afficher la transcription'"
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
[class.bg-red-600]="transcriptOpen()" [class.text-white]="transcriptOpen()"
[class.bg-slate-700]="!transcriptOpen()" [class.text-slate-200]="!transcriptOpen()" [class.hover:bg-slate-600]="!transcriptOpen()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 0v12h8V4H6zm1 2a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1zm0 3a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1zm0 3a1 1 0 011-1h2a1 1 0 110 2H8a1 1 0 01-1-1z" clip-rule="evenodd" />
</svg>
<span>Transcription</span>
</button>
@if (isLoggedIn()) {
<button (click)="openDownloadPanel()" title="Télécharger la vidéo"
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 3a1 1 0 011 1v7l2.293-2.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 11V4a1 1 0 011-1z" />
<path d="M4 17a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1z" />
</svg>
<span>Télécharger</span>
</button>
}
<a [href]="openOnProviderUrl() || '#'" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
[class.pointer-events-none]="!openOnProviderUrl()" [class.opacity-60]="!openOnProviderUrl()"
title="Ouvrir sur le site du fournisseur">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 3a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 11-2 0V5.414l-7.293 7.293a1 1 0 01-1.414-1.414L14.586 4H11a1 1 0 01-1-1z"/>
<path d="M5 4a1 1 0 011-1h3a1 1 0 110 2H7v8h8v-2a1 1 0 112 0v3a1 1 0 01-1 1H6a1 1 0 01-1-1V4z"/>
</svg>
<span>Ouvrir</span>
</a>
</div>
</section>
<!-- Description -->
<section class="mt-4 rounded-2xl border border-slate-700/60 bg-slate-800/50 p-4" aria-label="Description">
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Description</h2>
<p class="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-slate-200" [class.line-clamp-3]="!showFullDescription()">
{{ hasDescription() ? descriptionText() : 'Aucune description disponible.' }}
</p>
@if (showMoreAvailable()) {
<button (click)="toggleDescription()" class="mt-2 text-sm font-semibold text-red-400 transition hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 rounded">
{{ showFullDescription() ? 'Réduire' : 'Afficher plus' }}
</button>
}
</section>
<!-- Téléchargement -->
@if (isLoggedIn() && downloadOpen()) {
<section class="mt-4 rounded-2xl border border-slate-700 bg-slate-800/70 p-4" aria-label="Téléchargement">
<div class="flex items-center justify-between gap-3">
<h2 class="font-semibold text-slate-100">Téléchargement</h2>
<button (click)="closeDownloadPanel()" title="Fermer le panneau de téléchargement"
class="rounded-full p-1.5 text-slate-400 transition hover:bg-slate-700 hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400" aria-label="Fermer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</button>
</div>
<div class="mt-3 flex flex-wrap items-end gap-4">
<div class="min-w-[240px] flex-1">
<label for="fmtSel" class="mb-1 block text-sm text-slate-400">Format / Qualité</label>
@if (formatsLoading()) {
<div class="text-sm text-slate-400">Chargement des formats…</div>
} @else if (downloadFormats().length > 0) {
<select id="fmtSel" #fmtSel class="w-full rounded-lg border border-slate-700 bg-slate-900 px-2 py-2 text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
(change)="selectedFormatId.set(fmtSel.value || null)">
<option value="">Auto (meilleur)</option>
@for (f of downloadFormats(); track f.id) {
<option [value]="f.id">{{ f.label }}</option>
}
</select>
} @else if (downloadError()) {
<div class="text-sm text-red-300">{{ downloadError() }}</div>
} @else {
<div class="text-sm text-slate-400">Aucun format disponible.</div>
}
</div>
<div class="flex items-center gap-2">
<input id="audioOnly" #audioChk type="checkbox" class="h-4 w-4 accent-red-500"
(change)="audioOnly.set(audioChk.checked)" />
<label for="audioOnly" class="text-sm text-slate-300">Audio uniquement</label>
</div>
<div class="ml-auto">
@if (!jobId()) {
<button (click)="startDownload()" class="rounded-lg bg-red-600 px-4 py-2 font-semibold text-white transition hover:bg-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
Démarrer
</button>
} @else {
<button (click)="cancelDownload()" class="rounded-lg bg-slate-700 px-4 py-2 text-white transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
Annuler
</button>
}
</div>
</div>
@if (jobId()) {
<div class="mt-4">
<div class="mb-1 flex items-center gap-2 text-sm text-slate-300">
<span>Statut :</span>
<span class="font-semibold">{{ jobState() || 'en attente' }}</span>
<span aria-hidden="true">•</span>
<span class="tabular-nums">Progression : {{ jobProgress() }} %</span>
</div>
<div class="flex items-center gap-2 mt-2">
<input id="audioOnly" #audioChk type="checkbox" class="accent-red-500"
(change)="audioOnly.set(audioChk.checked)" />
<label for="audioOnly" class="text-sm text-slate-300">Audio uniquement</label>
</div>
<div class="ml-auto flex items-center gap-3 mt-2">
@if (!jobId()) {
<button (click)="startDownload()" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded">
Démarrer
</button>
} @else {
<button (click)="cancelDownload()" class="bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded">
Annuler
</button>
}
<div class="h-2.5 w-full rounded-full bg-slate-900" role="progressbar" [attr.aria-valuenow]="jobProgress()" aria-valuemin="0" aria-valuemax="100">
<div class="h-2.5 rounded-full bg-red-600 transition-all" [style.width.%]="jobProgress()"></div>
</div>
</div>
@if (jobId()) {
<div class="mt-4">
<div class="text-sm text-slate-300 mb-1 flex items-center gap-2">
<span>Statut:</span>
<span class="font-semibold">{{ jobState() || 'en attente' }}</span>
<span>•</span>
<span>Progression: {{ jobProgress() }}%</span>
</div>
<div class="w-full h-3 bg-slate-900 rounded">
<div class="h-3 bg-red-600 rounded" [style.width.%]="jobProgress()"></div>
</div>
</div>
@if (jobFileReady()) {
<div class="mt-4 flex items-center gap-3">
<button (click)="downloadFile()" class="bg-green-600 hover:bg-green-500 text-white font-semibold px-4 py-2 rounded">
Télécharger le fichier
</button>
@if (jobFileSize(); as size) {
<span class="text-sm text-slate-400">{{ formatBytes(size) }}</span>
}
</div>
}
}
@if (downloadError() && !formatsLoading()) {
<div class="mt-3 text-red-300 text-sm">{{ downloadError() }}</div>
}
</div>
}
</div>
<!-- Comments Section -->
<div class="mt-8">
<div class="flex items-center justify-between flex-wrap gap-4 mb-4">
<h2 class="text-xl font-bold">{{ v.comments.length }} Comments</h2>
@if (v.comments.length > 0) {
<button (click)="onSummarizeComments()"
[disabled]="isSummarizing() || !geminiReady"
class="flex items-center space-x-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-400 disabled:cursor-not-allowed px-3 py-2 rounded-full transition text-sm font-semibold">
@if (isSummarizing()) {
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Summarizing...</span>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-300" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v2.586l1.707-1.707a1 1 0 111.414 1.414l-1.707 1.707H15a1 1 0 110 2h-2.586l1.707 1.707a1 1 0 11-1.414 1.414L11 12.414V15a1 1 0 11-2 0v-2.586l-1.707 1.707a1 1 0 11-1.414-1.414L7.586 11H5a1 1 0 110-2h2.586L5.793 7.293a1 1 0 111.414-1.414L9 7.586V5a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
<span>Summarize Comments</span>
}
</button>
@if (!geminiReady && geminiReason) {
<div class="text-xs text-amber-300 ml-2">
{{ geminiReason }}
@if (jobFileReady()) {
<div class="mt-4 flex items-center gap-3">
<button (click)="downloadFile()" class="rounded-lg bg-green-600 px-4 py-2 font-semibold text-white transition hover:bg-green-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-400">
Télécharger le fichier
</button>
@if (jobFileSize(); as size) {
<span class="text-sm text-slate-400">{{ formatBytes(size) }}</span>
}
</div>
}
}
</div>
@if (downloadError() && !formatsLoading()) {
<div class="mt-3 text-sm text-red-300" role="alert">{{ downloadError() }}</div>
}
</section>
}
<!-- Summary/Error/Loading Display -->
<!-- Transcription -->
@if (transcriptOpen()) {
<section id="transcript-panel" class="mt-4 rounded-2xl border border-slate-700 bg-slate-800/70 p-4" role="region" aria-label="Transcription">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-slate-100">Transcription</h2>
@if (transcriptAvailable() && transcriptLines().length > 0) {
<span class="rounded-full bg-slate-700/70 px-2.5 py-0.5 text-xs tabular-nums text-slate-300">{{ transcriptCountLabel() }}</span>
}
<div class="ml-auto flex flex-wrap items-center gap-2">
@if (transcriptLanguages().length > 0) {
<label class="flex items-center gap-2 text-sm text-slate-400">
Langue
<select #langSel class="rounded-lg border border-slate-700 bg-slate-900 px-2 py-1 text-sm text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
[value]="selectedTranscriptLang()"
(change)="onTranscriptLangChange(langSel.value)">
@for (lang of transcriptLanguages(); track lang) {
<option [value]="lang" [selected]="lang === selectedTranscriptLang()">{{ lang }}</option>
}
</select>
</label>
}
@if (transcriptAvailable() && transcriptLines().length > 0) {
<button (click)="toggleTranscriptAutoScroll()" [attr.aria-pressed]="transcriptAutoScroll()" title="Suivre la lecture automatiquement"
class="rounded-full px-2.5 py-1 text-xs font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
[class.bg-red-600]="transcriptAutoScroll()" [class.text-white]="transcriptAutoScroll()"
[class.bg-slate-700]="!transcriptAutoScroll()" [class.text-slate-300]="!transcriptAutoScroll()">
Suivi {{ transcriptAutoScroll() ? 'activé' : 'coupé' }}
</button>
<button (click)="copyTranscript()" title="Copier la transcription filtrée"
class="rounded-full bg-slate-700 px-2.5 py-1 text-xs font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
{{ transcriptCopied() ? 'Copié !' : 'Copier' }}
</button>
}
</div>
</div>
@if (transcriptAvailable() && transcriptLines().length > 0) {
<div class="relative mt-3">
<svg xmlns="http://www.w3.org/2000/svg" class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" /></svg>
<input #tsearch type="search" placeholder="Rechercher dans la transcription…" [value]="transcriptSearch()"
(input)="onTranscriptSearch(tsearch.value)" aria-label="Rechercher dans la transcription"
class="w-full rounded-xl border border-slate-700 bg-slate-900 py-2 pl-9 pr-9 text-sm text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400" />
@if (transcriptSearch()) {
<button (click)="clearTranscriptSearch()" title="Effacer la recherche" aria-label="Effacer la recherche"
class="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 transition hover:bg-slate-700 hover:text-slate-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</button>
}
</div>
}
@if (transcriptLoading()) {
<div class="mt-4 space-y-2" aria-live="polite" aria-busy="true">
@for (sk of [1,2,3,4,5]; track sk) {
<div class="flex gap-3">
<div class="h-4 w-12 shrink-0 rounded bg-slate-700"></div>
<div class="h-4 flex-1 rounded bg-slate-700"></div>
</div>
}
<p class="pt-1 text-sm text-slate-400">Chargement de la transcription…</p>
</div>
} @else if (transcriptAvailable() && transcriptLines().length > 0) {
@if (filteredTranscriptLines().length > 0) {
<p class="mt-2 text-xs text-slate-500">Cliquez sur un horodatage pour sauter à ce passage.</p>
<div class="mt-2 max-h-96 space-y-1 overflow-y-auto rounded-xl bg-slate-900/50 p-2" (scroll)="onTranscriptScroll($event)" tabindex="0" aria-label="Lignes de transcription">
@for (line of filteredTranscriptLines(); track trackTranscriptLine($index, line); let i = $index) {
<div [id]="'tr-line-' + i"
class="flex items-baseline gap-3 rounded-lg px-2 py-1.5 text-sm transition"
[class.bg-red-500/15]="i === transcriptActiveIndex()"
[class.ring-1]="i === transcriptActiveIndex()" [class.ring-red-500/40]="i === transcriptActiveIndex()">
<button (click)="seekTranscript(line.t)" [title]="'Aller à ' + formatTimestamp(line.t)"
class="shrink-0 rounded px-1 font-mono text-xs tabular-nums text-red-300 transition hover:bg-red-500/20 hover:text-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
{{ formatTimestamp(line.t) }}
</button>
<span class="min-w-0 flex-1 leading-relaxed" [class.text-white]="i === transcriptActiveIndex()" [class.text-slate-300]="i !== transcriptActiveIndex()">{{ line.text }}</span>
</div>
}
</div>
} @else {
<div class="mt-3 rounded-xl bg-slate-900/50 p-4 text-center">
<p class="text-sm text-slate-300">Aucun passage ne correspond à « {{ transcriptSearch() }} ».</p>
<button (click)="clearTranscriptSearch()" class="mt-2 text-sm font-semibold text-red-400 hover:text-red-300">Effacer la recherche</button>
</div>
}
} @else {
@if (transcriptReason() === 'no_subtitles' || (!transcriptError() && transcriptLanguages().length === 0)) {
<div class="mt-3 rounded-xl bg-slate-900/50 p-4 text-center">
<p class="text-sm text-slate-400">Aucun sous-titre disponible pour cette vidéo.</p>
</div>
} @else {
<div class="mt-3 rounded-xl bg-slate-900/50 p-4 text-center">
<p class="text-sm text-slate-400">{{ transcriptError() || 'Aucun sous-titre disponible pour cette vidéo.' }}</p>
@if (transcriptRetryable()) {
<button (click)="retryTranscript()"
class="mt-3 inline-flex items-center gap-2 rounded-full bg-slate-700 px-3 py-1.5 text-sm font-semibold text-slate-100 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01-.61-1.276z" clip-rule="evenodd" />
</svg>
Réessayer
</button>
}
</div>
}
}
</section>
}
<!-- Commentaires -->
<section class="mt-8" aria-label="Commentaires">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-bold text-white">{{ v.comments.length }} commentaire{{ v.comments.length > 1 ? 's' : '' }}</h2>
@if (v.comments.length > 0) {
<div class="flex items-center gap-2">
<button (click)="onSummarizeComments()"
[disabled]="isSummarizing() || !geminiReady"
class="flex items-center gap-2 rounded-full bg-slate-700 px-3 py-2 text-sm font-semibold text-slate-100 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 disabled:cursor-not-allowed disabled:bg-slate-800 disabled:text-slate-400">
@if (isSummarizing()) {
<svg class="h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Résumé en cours…</span>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-yellow-300" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v2.586l1.707-1.707a1 1 0 111.414 1.414l-1.707 1.707H15a1 1 0 110 2h-2.586l1.707 1.707a1 1 0 11-1.414 1.414L11 12.414V15a1 1 0 11-2 0v-2.586l-1.707 1.707a1 1 0 11-1.414-1.414L7.586 11H5a1 1 0 110-2h2.586L5.793 7.293a1 1 0 111.414-1.414L9 7.586V5a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
<span>Résumer les commentaires</span>
}
</button>
</div>
}
</div>
@if (!geminiReady && geminiReason && v.comments.length > 0) {
<div class="mb-4 text-xs text-amber-300">{{ geminiReason }}</div>
}
@if (isSummarizing() || summary() || summaryError()) {
<div class="mb-6">
@if (isSummarizing() && !summary() && !summaryError()) {
<div class="bg-slate-800 p-4 rounded-lg animate-pulse">
<div class="h-4 bg-slate-700 rounded w-1/4 mb-3"></div>
<div class="space-y-2">
<div class="h-4 bg-slate-700 rounded"></div>
<div class="h-4 bg-slate-700 rounded w-5/6"></div>
<div class="h-4 bg-slate-700 rounded w-3/4"></div>
</div>
<div class="animate-pulse rounded-xl bg-slate-800 p-4">
<div class="mb-3 h-4 w-1/4 rounded bg-slate-700"></div>
<div class="space-y-2">
<div class="h-4 rounded bg-slate-700"></div>
<div class="h-4 w-5/6 rounded bg-slate-700"></div>
<div class="h-4 w-3/4 rounded bg-slate-700"></div>
</div>
</div>
} @else if (summary(); as summaryText) {
<div class="bg-gradient-to-br from-slate-800 to-slate-800/50 p-4 rounded-lg border border-slate-700">
<h3 class="text-lg font-bold text-yellow-300 flex items-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<div class="rounded-xl border border-slate-700 bg-gradient-to-br from-slate-800 to-slate-800/50 p-4">
<h3 class="flex items-center gap-2 text-lg font-bold text-yellow-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v2.586l1.707-1.707a1 1 0 111.414 1.414l-1.707 1.707H15a1 1 0 110 2h-2.586l1.707 1.707a1 1 0 11-1.414 1.414L11 12.414V15a1 1 0 11-2 0v-2.586l-1.707 1.707a1 1 0 11-1.414-1.414L7.586 11H5a1 1 0 110-2h2.586L5.793 7.293a1 1 0 111.414-1.414L9 7.586V5a1 1 0 011-1z" clip-rule="evenodd" />
</svg>
<span>AI Summary</span>
<span>Résumé IA</span>
</h3>
<p class="mt-2 text-slate-300 whitespace-pre-wrap">{{ summaryText }}</p>
<p class="mt-2 whitespace-pre-wrap text-slate-300">{{ summaryText }}</p>
</div>
} @else if (summaryError(); as errorText) {
<div class="bg-red-900/50 border border-red-700 text-red-300 p-4 rounded-lg">
<div class="rounded-xl border border-red-700 bg-red-900/50 p-4 text-red-300" role="alert">
<p>{{ errorText }}</p>
</div>
}
</div>
}
<div class="space-y-6">
@for (comment of v.comments; track comment.author + comment.commentedTime) {
<div class="flex items-start space-x-4">
<img [src]="comment.thumbnail" alt="" class="w-10 h-10 rounded-full">
<div class="flex-1">
<div class="flex items-center space-x-2 text-sm">
<span class="font-semibold text-slate-200">{{ comment.author }}</span>
<span class="text-slate-400">{{ comment.commentedTime }}</span>
@if (v.comments.length > 0) {
<div class="space-y-5">
@for (comment of v.comments; track comment.author + comment.commentedTime) {
<article class="flex items-start gap-3">
<img [src]="comment.thumbnail" alt="" class="h-10 w-10 shrink-0 rounded-full bg-slate-700" loading="lazy">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-x-2 text-sm">
<span class="font-semibold text-slate-200">{{ comment.author }}</span>
<span class="text-slate-500">{{ comment.commentedTime }}</span>
</div>
<p class="mt-1 break-words text-sm leading-relaxed text-slate-300">{{ comment.commentText }}</p>
</div>
<p class="mt-1 text-slate-300">{{ comment.commentText }}</p>
</article>
}
</div>
} @else {
<p class="rounded-xl bg-slate-800/50 p-4 text-center text-sm text-slate-400">Aucun commentaire pour cette vidéo.</p>
}
</section>
</div>
<!-- ============ Sidebar ============ -->
<aside class="min-w-0" aria-label="Vidéos suivantes">
<div class="lg:sticky lg:top-4">
<h2 class="mb-3 text-lg font-bold text-white">À suivre</h2>
<div class="space-y-2">
@for (related of v.relatedStreams; track related.videoId) {
<div class="group rounded-xl p-2 transition hover:bg-slate-800/60 focus-within:bg-slate-800/60">
<a [routerLink]="['/watch', related.videoId]" [queryParams]="watchQueryParams(related)" [state]="{ video: related }" class="flex items-start gap-3 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
<div class="relative w-40 shrink-0">
<img [src]="related.thumbnail" [alt]="related.title" class="h-24 w-full rounded-xl object-cover" loading="lazy">
<div class="absolute right-1.5 top-1.5">
<app-provider-badge [provider]="related.provider"></app-provider-badge>
</div>
@if (related.duration && related.duration > 0) {
<div class="absolute bottom-1.5 right-1.5 rounded bg-black/75 px-1.5 py-0.5 text-[10px] tabular-nums text-white">
{{ related.duration | duration }}
</div>
}
</div>
<div class="min-w-0 flex-1">
<h3 class="line-clamp-2 text-sm font-semibold leading-snug text-slate-100 group-hover:text-red-300">{{ related.title }}</h3>
<div class="mt-1 text-xs tabular-nums text-slate-400">{{ formatNumber(related.views) }} vues</div>
</div>
</a>
<div class="mt-1.5 pl-1">
<app-channel-identity
size="sm"
[provider]="related.provider"
[externalId]="related.channelExternalId ?? null"
[avatarUrl]="related.channelAvatarUrl || related.uploaderAvatar || null"
[name]="related.uploaderName || null"
></app-channel-identity>
</div>
</div>
} @empty {
<p class="rounded-xl bg-slate-800/50 p-4 text-center text-sm text-slate-400">Aucune suggestion pour le moment.</p>
}
</div>
</div>
</div>
<!-- Sidebar: Related Videos -->
<div class="lg:w-1/3">
<h2 class="text-xl font-bold mb-4">Up next</h2>
<div class="space-y-4">
@for (related of v.relatedStreams; track related.videoId) {
<div class="group hover:bg-slate-800/50 p-2 rounded-lg transition">
<a [routerLink]="['/watch', related.videoId]" [queryParams]="watchQueryParams(related)" [state]="{ video: related }" class="flex items-start space-x-3">
<div class="relative w-40 flex-shrink-0">
<img [src]="related.thumbnail" [alt]="related.title" class="w-full h-24 object-cover rounded">
<div class="absolute top-1.5 right-1.5">
<app-provider-badge [provider]="related.provider"></app-provider-badge>
</div>
<div *ngIf="related.duration && related.duration > 0" class="absolute bottom-1.5 right-1.5 bg-black/75 text-white text-[10px] px-1.5 py-0.5 rounded">
{{ related.duration | duration }}
</div>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-sm line-clamp-2 group-hover:text-red-400">{{ related.title }}</h3>
<div class="text-xs text-slate-400">{{ formatNumber(related.views) }} views</div>
</div>
</a>
<div class="mt-1 pl-1">
<app-channel-identity
size="sm"
[provider]="related.provider"
[externalId]="related.channelExternalId ?? null"
[avatarUrl]="related.channelAvatarUrl || related.uploaderAvatar || null"
[name]="related.uploaderName || null"
></app-channel-identity>
</div>
</div>
}
</div>
</div>
</aside>
</div>
} @else {
<div class="text-center py-20">
<h2 class="text-2xl font-bold text-slate-300">Video not found</h2>
<p class="text-slate-400 mt-2">The requested video could not be loaded.</p>
<a routerLink="/" class="mt-6 inline-block bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-full transition">Go to Home</a>
<div class="py-20 text-center">
<h2 class="text-2xl font-bold text-slate-300">Vidéo introuvable</h2>
<p class="mt-2 text-slate-400">La vidéo demandée n'a pas pu être chargée.</p>
<a routerLink="/" class="mt-6 inline-block rounded-full bg-red-600 px-5 py-2.5 font-bold text-white transition hover:bg-red-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">Retour à l'accueil</a>
</div>
}
</div>
</div>
+193 -7
View File
@@ -290,11 +290,29 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
liked = signal<boolean>(false);
likeBusy = signal<boolean>(false);
// --- Transcript state (Step 16, Phase 1: display + language selector, no seek) ---
// --- Transcript state (Phase 2: display + search + click-to-seek + auto-scroll) ---
transcriptOpen = signal(false);
transcriptLoading = signal(false);
transcriptAvailable = signal(false);
transcriptError = signal<string | null>(null);
transcriptSearch = signal('');
transcriptAutoScroll = signal(true);
transcriptCopied = signal(false);
transcriptActiveIndex = signal(-1);
linkCopied = signal(false);
filteredTranscriptLines = computed(() => {
const q = this.transcriptSearch().trim().toLowerCase();
const lines = this.transcriptLines();
if (!q) return lines;
return lines.filter((l) => l.text.toLowerCase().includes(q));
});
transcriptCountLabel = computed(() => {
const total = this.transcriptLines().length;
const shown = this.filteredTranscriptLines().length;
return shown !== total ? `${shown} / ${total} passages` : `${total} passages`;
});
private transcriptClock: any = null;
@ViewChild(VideoPlayerComponent, { static: false }) nativePlayer?: VideoPlayerComponent;
// Machine-readable unavailability reason from the API: 'no_subtitles' |
// 'provider_unsupported' | 'temporarily_unavailable' | null.
transcriptReason = signal<string | null>(null);
@@ -523,6 +541,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
ngOnDestroy(): void {
this.routeSubscription.unsubscribe();
this.stopTranscriptClock();
}
// Toggle like status for the current video
@@ -837,11 +856,15 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
// Copy current page link to clipboard
async copyLink(): Promise<void> {
const done = () => {
this.linkCopied.set(true);
setTimeout(() => this.linkCopied.set(false), 2000);
};
try {
const href = typeof window !== 'undefined' && window.location ? window.location.href : '';
if (!href) return;
await navigator.clipboard.writeText(href);
console.log('[Watch] Link copied to clipboard');
done();
} catch (e) {
try {
// Fallback if Clipboard API not available
@@ -853,7 +876,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
console.log('[Watch] Link copied to clipboard (fallback)');
done();
} catch {}
}
}
@@ -890,7 +913,11 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.selectedQuality.set(value || null);
}
// --- Transcript logic (Step 16) ---
// Langues déjà tentées pour la vidéo courante (auto-fallback "n'importe
// quelle langue" : on ne réessaie jamais deux fois la même).
private transcriptTriedLangs = new Set<string>();
// --- Transcript logic (Phase 2) ---
private resetTranscriptUi() {
this.transcriptOpen.set(false);
this.transcriptLoading.set(false);
@@ -900,6 +927,12 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.transcriptRetryable.set(false);
this.transcriptLanguages.set([]);
this.transcriptLines.set([]);
this.transcriptSearch.set('');
this.transcriptCopied.set(false);
this.transcriptActiveIndex.set(-1);
this.transcriptAutoScroll.set(true);
this.stopTranscriptClock();
this.transcriptTriedLangs.clear();
try {
const prefLang = String(this.users.preferences()?.language || '').trim().toLowerCase().slice(0, 12);
this.selectedTranscriptLang.set(prefLang || 'fr');
@@ -911,8 +944,13 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
toggleTranscript() {
const next = !this.transcriptOpen();
this.transcriptOpen.set(next);
if (next && this.transcriptLines().length === 0 && !this.transcriptLoading()) {
this.loadTranscript();
if (next) {
this.startTranscriptClock();
if (this.transcriptLines().length === 0 && !this.transcriptLoading()) {
this.loadTranscript();
}
} else {
this.stopTranscriptClock();
}
}
@@ -996,9 +1034,147 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
retryTranscript() {
if (this.transcriptLoading()) return;
this.transcriptLines.set([]);
this.transcriptActiveIndex.set(-1);
// Nouveau round manuel : on peut re-tenter toutes les langues.
this.transcriptTriedLangs.clear();
this.loadTranscript();
}
/** Clic sur un timestamp : saute à ce moment (natif direct, iframe via ?start=). */
seekTranscript(seconds: number): void {
const t = Math.max(0, Math.floor(Number(seconds) || 0));
try {
const native = this.nativePlayer?.el;
if (native && Number.isFinite(native.duration)) {
native.currentTime = t;
try { native.play?.(); } catch {}
this.startPositionSeconds.set(t);
this.updateTranscriptActive(t);
this.scrollTranscriptTo(t);
return;
}
} catch {}
// Embeds cross-origin (YouTube/Dailymotion/PeerTube/Odysee) : on
// reconstruit l'URL d'embed avec le offset (embedUrl dépend du signal).
this.startPositionSeconds.set(t);
this.updateTranscriptActive(t);
this.scrollTranscriptTo(t);
}
onTranscriptSearch(value: string): void {
this.transcriptSearch.set(String(value ?? ''));
}
clearTranscriptSearch(): void {
this.transcriptSearch.set('');
}
toggleTranscriptAutoScroll(): void {
this.transcriptAutoScroll.update((v) => !v);
}
/** L'utilisateur scrolle manuellement : on coupe l'auto-scroll pour ne pas le gêner. */
onTranscriptScroll(ev: Event): void {
if (!this.transcriptAutoScroll()) return;
try {
const el = ev.target as HTMLElement | null;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (!nearBottom) this.transcriptAutoScroll.set(false);
} catch {}
}
async copyTranscript(): Promise<void> {
const text = this.filteredTranscriptLines()
.map((l) => `${this.formatTimestamp(l.t)} ${l.text}`)
.join('\n');
if (!text) return;
try {
await navigator.clipboard.writeText(text);
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
} catch {}
}
this.transcriptCopied.set(true);
setTimeout(() => this.transcriptCopied.set(false), 2000);
}
trackTranscriptLine(i: number, line: { t: number; text: string }): string {
return `${Math.round(line.t * 10)}-${line.text.slice(0, 24)}-${i}`;
}
formatTimestamp(t: number): string {
const s = Math.max(0, Math.floor(Number(t) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}
private updateTranscriptActive(currentSec: number): void {
const lines = this.transcriptLines();
if (!lines.length) return;
let idx = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].t <= currentSec + 0.25) idx = i;
else break;
}
if (idx !== this.transcriptActiveIndex()) {
this.transcriptActiveIndex.set(idx);
if (this.transcriptAutoScroll() && idx >= 0) {
try {
document.getElementById(`tr-line-${idx}`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} catch {}
}
}
}
private scrollTranscriptTo(seconds: number): void {
this.updateTranscriptActive(seconds);
}
private startTranscriptClock(): void {
this.stopTranscriptClock();
// Native <video> uniquement (les iframes cross-origin n'exposent pas currentTime).
this.transcriptClock = setInterval(() => {
try {
const v = this.nativePlayer?.el;
if (v && !v.paused && Number.isFinite(v.currentTime)) this.updateTranscriptActive(v.currentTime);
} catch {}
}, 1000);
}
private stopTranscriptClock(): void {
if (this.transcriptClock) {
clearInterval(this.transcriptClock);
this.transcriptClock = null;
}
}
/**
* Auto-fallback "n'importe quelle langue" : si la langue demandée échoue
* mais que le backend signale d'autres pistes, on relance automatiquement
* avec la première non encore tentée. Retourne true si un retry est parti.
*/
private autoFallbackTranscriptLang(languages: unknown): boolean {
if (!Array.isArray(languages)) return false;
const next = (languages as unknown[])
.map((l) => String(l || '').trim().slice(0, 12))
.find((l) => !!l && !this.transcriptTriedLangs.has(l.toLowerCase()));
if (!next) return false;
this.selectedTranscriptLang.set(next);
// loadTranscript() ré-enregistre dans triedLangs et relance la requête.
this.loadTranscript();
return true;
}
loadTranscript() {
const p = this.provider();
const id = this.videoId();
@@ -1014,8 +1190,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.transcriptError.set(null);
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
const requestedLang = this.selectedTranscriptLang();
this.transcriptTriedLangs.add(String(requestedLang || '').toLowerCase());
const opts = this.buildProviderOpts() as Record<string, string>;
const params = new URLSearchParams({ lang: this.selectedTranscriptLang() });
const params = new URLSearchParams({ lang: requestedLang });
if (opts['instance']) params.set('instance', opts['instance']);
if (opts['slug']) params.set('slug', opts['slug']);
const url = `${this.apiBase()}/transcript/${encodeURIComponent(p)}/${encodeURIComponent(id)}?${params.toString()}`;
@@ -1029,6 +1207,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.selectedTranscriptLang.set(data.lang);
}
if (!data?.available) {
// La langue demandée n'a rien donné mais d'autres pistes existent :
// on bascule automatiquement dessus au lieu d'afficher une erreur.
if (this.autoFallbackTranscriptLang(data?.languages)) return;
const st = this.transcriptStatusFor(data);
this.transcriptReason.set(st.reason);
this.transcriptRetryable.set(st.retryable);
@@ -1037,6 +1218,8 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
this.transcriptReason.set(null);
this.transcriptRetryable.set(false);
this.transcriptError.set(null);
this.transcriptActiveIndex.set(-1);
this.startTranscriptClock();
}
},
error: (err) => {
@@ -1046,6 +1229,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
const body = err?.error;
if (body && typeof body === 'object' && Array.isArray(body.languages)) {
this.transcriptLanguages.set(body.languages);
// Erreur réseau/502 mais d'autres pistes connues : on essaie la
// suivante automatiquement (une fois chacune, garde anti-boucle).
if (this.autoFallbackTranscriptLang(body.languages)) return;
}
const raw =
(body && typeof body === 'object' && (body.error || body.details || body.reason)) ||