feat(watch): suivi transcript reel sur embeds YouTube
CI / build-and-test (push) Failing after 8m17s
CI / build-and-test (push) Failing after 8m17s
- ecoute des events du player via postMessage (listening -> onReady/infoDelivery/onStateChange), extrapolation du temps entre events, fige a la pause; surlignage + autoscroll actifs aussi en embed - seek: ancre l'estimation sur la destination (pas de saut visuel) - toggle Suivi: saut immediat au passage en cours + intitulés explicites; (load) iframe pour (re)abonnement; reset par video
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
<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"
|
||||
<iframe #embedFrame class="h-full w-full" [src]="src" frameborder="0" (load)="onEmbedFrameLoad()"
|
||||
referrerpolicy="origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen title="Lecteur vidéo"></iframe>
|
||||
@@ -400,7 +400,8 @@
|
||||
</label>
|
||||
}
|
||||
@if (transcriptAvailable() && transcriptLines().length > 0) {
|
||||
<button (click)="toggleTranscriptAutoScroll()" [attr.aria-pressed]="transcriptAutoScroll()" title="Suivre la lecture automatiquement"
|
||||
<button (click)="toggleTranscriptAutoScroll()" [attr.aria-pressed]="transcriptAutoScroll()"
|
||||
[title]="transcriptAutoScroll() ? 'Suivi activé : la transcription défile et surligne le passage en cours de lecture' : 'Activer le suivi : défilement auto et surlignage du passage lu'"
|
||||
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()">
|
||||
|
||||
@@ -315,6 +315,55 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
});
|
||||
private transcriptClock: any = null;
|
||||
@ViewChild(VideoPlayerComponent, { static: false }) nativePlayer?: VideoPlayerComponent;
|
||||
|
||||
// --- Suivi de lecture YouTube (iframe cross-origin) ---
|
||||
// Le navigateur ne peut pas lire currentTime d'une iframe YouTube, mais
|
||||
// le player pousse ses événements (onReady / infoDelivery avec
|
||||
// currentTime / onStateChange) quand on s'y abonne via postMessage
|
||||
// (enablejsapi=1). Entre deux événements, on extrapole comme le fait
|
||||
// l'API officielle : temps + écoulé × vitesse quand ça joue.
|
||||
private ytHeard = false;
|
||||
private ytLastTime = 0;
|
||||
private ytLastTs = 0;
|
||||
private ytPlaying = false;
|
||||
|
||||
private handlePlayerMessage = (ev: MessageEvent): void => {
|
||||
try {
|
||||
const origin = String(ev?.origin || '');
|
||||
if (!origin.includes('youtube')) return;
|
||||
const data = typeof ev?.data === 'string' ? JSON.parse(ev.data) : ev?.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.event === 'infoDelivery' && data.info && typeof data.info === 'object') {
|
||||
const ct = Number(data.info.currentTime);
|
||||
if (Number.isFinite(ct) && ct >= 0) {
|
||||
this.ytLastTime = ct;
|
||||
this.ytLastTs = Date.now();
|
||||
this.ytHeard = true;
|
||||
}
|
||||
const st = Number(data.info.playerState);
|
||||
if (Number.isFinite(st)) this.ytPlaying = st === 1;
|
||||
} else if (data.event === 'onStateChange') {
|
||||
const st = Number(data.info);
|
||||
if (Number.isFinite(st)) {
|
||||
// Fige le temps estimé à la pause/fin pour éviter toute dérive.
|
||||
if (this.ytPlaying && st !== 1) this.ytLastTime = this.ytEstimatedTime();
|
||||
this.ytPlaying = st === 1;
|
||||
this.ytLastTs = Date.now();
|
||||
}
|
||||
} else if (data.event === 'onReady') {
|
||||
this.onEmbedFrameLoad();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
private ytEstimatedTime(): number {
|
||||
try {
|
||||
if (this.ytPlaying) return Math.max(0, this.ytLastTime + (Date.now() - this.ytLastTs) / 1000);
|
||||
return Math.max(0, this.ytLastTime);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
// Machine-readable unavailability reason from the API: 'no_subtitles' |
|
||||
// 'provider_unsupported' | 'temporarily_unavailable' | null.
|
||||
transcriptReason = signal<string | null>(null);
|
||||
@@ -504,6 +553,8 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Écoute globale des événements du player YouTube embarqué (suivi transcript).
|
||||
try { window.addEventListener('message', this.handlePlayerMessage); } catch {}
|
||||
// Charge le cache d'abonnements pour que isSubscribed() soit fiable sur /watch.
|
||||
try {
|
||||
if (this.subs.items().length === 0) this.subs.list().subscribe();
|
||||
@@ -551,6 +602,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
ngOnDestroy(): void {
|
||||
this.routeSubscription.unsubscribe();
|
||||
this.stopTranscriptClock();
|
||||
try { window.removeEventListener('message', this.handlePlayerMessage); } catch {}
|
||||
}
|
||||
|
||||
// Toggle like status for the current video
|
||||
@@ -953,6 +1005,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.transcriptSearch.set('');
|
||||
this.transcriptCopied.set(false);
|
||||
this.transcriptActiveIndex.set(-1);
|
||||
this.ytHeard = false;
|
||||
this.ytLastTime = 0;
|
||||
this.ytLastTs = 0;
|
||||
this.ytPlaying = false;
|
||||
this.transcriptAutoScroll.set(true);
|
||||
this.stopTranscriptClock();
|
||||
this.transcriptTriedLangs.clear();
|
||||
@@ -1110,6 +1166,10 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
const win = frame?.contentWindow;
|
||||
if (!frame || !win) return false;
|
||||
const msg = JSON.stringify({ event: 'command', func: 'seekTo', args: [seconds, true] });
|
||||
// Ancre l'estimation du suivi sur la destination (évite un saut
|
||||
// visuel en arrière avant le prochain infoDelivery).
|
||||
this.ytLastTime = seconds;
|
||||
this.ytLastTs = Date.now();
|
||||
try { win.postMessage(msg, '*'); } catch { return false; }
|
||||
// Le player expose l'API JS ~1 s après chargement : on rejoue la
|
||||
// commande une fois pour les seeks précoces.
|
||||
@@ -1129,7 +1189,16 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
toggleTranscriptAutoScroll(): void {
|
||||
this.transcriptAutoScroll.update((v) => !v);
|
||||
const next = !this.transcriptAutoScroll();
|
||||
this.transcriptAutoScroll.set(next);
|
||||
// Feedback immédiat : en activant, on saute au passage en cours
|
||||
// (sinon rien ne se passe visiblement jusqu'au changement de ligne).
|
||||
if (next) {
|
||||
const idx = this.transcriptActiveIndex();
|
||||
if (idx >= 0) {
|
||||
try { document.getElementById(`tr-line-${idx}`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** L'utilisateur scrolle manuellement : on coupe l'auto-scroll pour ne pas le gêner. */
|
||||
@@ -1211,13 +1280,35 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
||||
this.updateTranscriptActive(seconds);
|
||||
}
|
||||
|
||||
/** L'iframe signale son chargement : on (ré)abonne aux événements player. */
|
||||
onEmbedFrameLoad(): void {
|
||||
if (this.provider() !== 'youtube') return;
|
||||
try {
|
||||
const win = this.embedFrame?.nativeElement?.contentWindow;
|
||||
if (!win) return;
|
||||
win.postMessage(JSON.stringify({ event: 'listening', id: 1 }), '*');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private startTranscriptClock(): void {
|
||||
this.stopTranscriptClock();
|
||||
// Native <video> uniquement (les iframes cross-origin n'exposent pas currentTime).
|
||||
// (Ré)abonne aux événements YouTube même si l'iframe a chargé avant
|
||||
// l'ouverture de la transcription (l'envoi est idempotent).
|
||||
try { this.onEmbedFrameLoad(); } catch {}
|
||||
this.transcriptClock = setInterval(() => {
|
||||
// 1) Lecteur natif : position exacte.
|
||||
try {
|
||||
const v = this.nativePlayer?.el;
|
||||
if (v && !v.paused && Number.isFinite(v.currentTime)) this.updateTranscriptActive(v.currentTime);
|
||||
if (v && !v.paused && Number.isFinite(v.currentTime)) {
|
||||
this.updateTranscriptActive(v.currentTime);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
// 2) YouTube : temps estimé depuis les événements du player.
|
||||
try {
|
||||
if (this.provider() === 'youtube' && this.ytHeard) {
|
||||
this.updateTranscriptActive(this.ytEstimatedTime());
|
||||
}
|
||||
} catch {}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user