video: samodejna poglavja za intervjuje + Clip/SeekToAction oznacba
Poglavja se zdaj generirajo iz YouTube podnapisov (chapters.py, dnevno ob 9:20) in gredo tudi v opis videa na YouTubu. Na strani so poglavja klikljiva (?t=<sekunde>, predvajalnik se odpre na tem mestu), v VideoObject pa gresta hasPart/Clip in SeekToAction, iz katerih Google naredi Wichtige Momente.
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, useRoute } from "wouter";
|
import { Link, useRoute } from "wouter";
|
||||||
import { ArrowLeft, Play, Clock, Youtube } from "lucide-react";
|
import { ArrowLeft, Play, Clock, Youtube } from "lucide-react";
|
||||||
@@ -29,6 +30,11 @@ export interface YtVideo {
|
|||||||
youtubeUrl: string;
|
youtubeUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function chapSecs(t: string) {
|
||||||
|
const p = String(t).split(":").map((x) => parseInt(x, 10) || 0);
|
||||||
|
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
|
||||||
|
}
|
||||||
|
|
||||||
export function dauer(s: number) {
|
export function dauer(s: number) {
|
||||||
const m = Math.floor(s / 60);
|
const m = Math.floor(s / 60);
|
||||||
const r = s % 60;
|
const r = s % 60;
|
||||||
@@ -58,6 +64,12 @@ function RelatedCard({ v }: { v: YtVideo }) {
|
|||||||
|
|
||||||
export default function VideoDetailPage() {
|
export default function VideoDetailPage() {
|
||||||
const [, params] = useRoute("/video/:slug");
|
const [, params] = useRoute("/video/:slug");
|
||||||
|
// ?t=<sekunde> odpre predvajalnik na tem mestu; enak parameter uporablja
|
||||||
|
// tudi Clip oznacba, iz katere Google naredi "Wichtige Momente".
|
||||||
|
const [startAt, setStartAt] = useState(() => {
|
||||||
|
const n = parseInt(new URLSearchParams(window.location.search).get("t") || "", 10);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||||
|
});
|
||||||
const slug = params?.slug || "";
|
const slug = params?.slug || "";
|
||||||
const { data, isLoading } = useQuery<{ item: YtVideo; related: YtVideo[] }>({
|
const { data, isLoading } = useQuery<{ item: YtVideo; related: YtVideo[] }>({
|
||||||
queryKey: [`/api/yt-videos/${slug}`],
|
queryKey: [`/api/yt-videos/${slug}`],
|
||||||
@@ -92,7 +104,8 @@ export default function VideoDetailPage() {
|
|||||||
YouTube v tem primeru oglasov ne servira. */}
|
YouTube v tem primeru oglasov ne servira. */}
|
||||||
<div className="rounded-lg overflow-hidden bg-black aspect-video">
|
<div className="rounded-lg overflow-hidden bg-black aspect-video">
|
||||||
<iframe
|
<iframe
|
||||||
src={v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`}
|
key={startAt}
|
||||||
|
src={`${v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`}${startAt ? `?start=${startAt}` : ""}`}
|
||||||
title={v.shortTitle}
|
title={v.shortTitle}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
style={{ border: 0 }}
|
style={{ border: 0 }}
|
||||||
@@ -135,8 +148,14 @@ export default function VideoDetailPage() {
|
|||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{v.chapters.map((c) => (
|
{v.chapters.map((c) => (
|
||||||
<li key={c.t} className="text-sm">
|
<li key={c.t} className="text-sm">
|
||||||
|
<a
|
||||||
|
href={`?t=${chapSecs(c.t)}`}
|
||||||
|
onClick={(e) => { e.preventDefault(); setStartAt(chapSecs(c.t)); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
<span className="inline-block w-14 font-mono text-primary">{c.t}</span>
|
<span className="inline-block w-14 font-mono text-primary">{c.t}</span>
|
||||||
<span className="text-card-foreground">{c.label}</span>
|
<span className="text-card-foreground">{c.label}</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+31
-3
@@ -135,6 +135,20 @@ export function serveStatic(app: Express) {
|
|||||||
.replace(/https?:\/\/\S+/g, "").replace(/\s+/g, " ").trim().slice(0, 900);
|
.replace(/https?:\/\/\S+/g, "").replace(/\s+/g, " ").trim().slice(0, 900);
|
||||||
const mins = Math.floor((v.durationS || 0) / 60), secs = (v.durationS || 0) % 60;
|
const mins = Math.floor((v.durationS || 0) / 60), secs = (v.durationS || 0) % 60;
|
||||||
const embedUrl = v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`;
|
const embedUrl = v.embedUrl || `https://www.youtube-nocookie.com/embed/${v.ytId}`;
|
||||||
|
// Poglavja: iz "1:24" naredimo sekunde, da jih lahko damo v Clip
|
||||||
|
// oznacbo (Google iz nje naredi "Wichtige Momente" pod videom) in da
|
||||||
|
// je vsako poglavje klikljiva povezava z ?t=<sekunde>.
|
||||||
|
const chapSecs = (t: string) => {
|
||||||
|
const p = String(t).split(":").map((x) => parseInt(x, 10) || 0);
|
||||||
|
return p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p[0] * 60 + p[1];
|
||||||
|
};
|
||||||
|
const chaps = (v.chapters || [])
|
||||||
|
.map((c: any) => ({ ...c, s: chapSecs(c.t) }))
|
||||||
|
.filter((c: any) => c.s < (v.durationS || 0))
|
||||||
|
.sort((a: any, b: any) => a.s - b.s);
|
||||||
|
const startAt = parseInt(String((req.query as any)?.t || ""), 10);
|
||||||
|
const embedSrc = Number.isFinite(startAt) && startAt > 0
|
||||||
|
? `${embedUrl}?start=${startAt}` : embedUrl;
|
||||||
const videoLd = {
|
const videoLd = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "VideoObject",
|
"@type": "VideoObject",
|
||||||
@@ -153,6 +167,20 @@ export function serveStatic(app: Express) {
|
|||||||
"logo": { "@type": "ImageObject", "url": `${canonicalBase}/og-image.jpg` },
|
"logo": { "@type": "ImageObject", "url": `${canonicalBase}/og-image.jpg` },
|
||||||
},
|
},
|
||||||
"mainEntityOfPage": { "@type": "WebPage", "@id": pageUrl },
|
"mainEntityOfPage": { "@type": "WebPage", "@id": pageUrl },
|
||||||
|
...(chaps.length >= 3 ? {
|
||||||
|
hasPart: chaps.map((c: any, i: number) => ({
|
||||||
|
"@type": "Clip",
|
||||||
|
"name": c.label,
|
||||||
|
"startOffset": c.s,
|
||||||
|
"endOffset": i + 1 < chaps.length ? chaps[i + 1].s : (v.durationS || c.s + 30),
|
||||||
|
"url": `${pageUrl}?t=${c.s}`,
|
||||||
|
})),
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SeekToAction",
|
||||||
|
"target": `${pageUrl}?t={seek_to_second_number}`,
|
||||||
|
"startOffset-input": "required name=seek_to_second_number",
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
const tags = [
|
const tags = [
|
||||||
`<meta property="og:title" content="${escapeHtml(v.shortTitle)}" />`,
|
`<meta property="og:title" content="${escapeHtml(v.shortTitle)}" />`,
|
||||||
@@ -175,8 +203,8 @@ export function serveStatic(app: Express) {
|
|||||||
].join("\n ");
|
].join("\n ");
|
||||||
template = template.replace(/<title>[^<]*<\/title>/, tags);
|
template = template.replace(/<title>[^<]*<\/title>/, tags);
|
||||||
|
|
||||||
const chapterHtml = (v.chapters || []).length
|
const chapterHtml = chaps.length
|
||||||
? `<h2>Kapitel</h2><ul>${v.chapters.map((c: any) => `<li>${escapeHtml(c.t)} – ${escapeHtml(c.label)}</li>`).join("")}</ul>`
|
? `<h2>Kapitel</h2><ul>${chaps.map((c: any) => `<li><a href="${escapeHtml(pageUrl)}?t=${c.s}">${escapeHtml(c.t)}</a> – ${escapeHtml(c.label)}</li>`).join("")}</ul>`
|
||||||
: "";
|
: "";
|
||||||
// Celoten opis gre v SSR, da ga Google prebere brez JavaScripta.
|
// Celoten opis gre v SSR, da ga Google prebere brez JavaScripta.
|
||||||
// Prej je bil v HTML samo naslov in teaser (~320 znakov), ostalo je
|
// Prej je bil v HTML samo naslov in teaser (~320 znakov), ostalo je
|
||||||
@@ -190,7 +218,7 @@ export function serveStatic(app: Express) {
|
|||||||
const ssr = [
|
const ssr = [
|
||||||
'<article class="ssr-content">',
|
'<article class="ssr-content">',
|
||||||
`<h1>${escapeHtml(v.shortTitle)}</h1>`,
|
`<h1>${escapeHtml(v.shortTitle)}</h1>`,
|
||||||
`<iframe width="640" height="360" src="${escapeHtml(embedUrl)}" title="${escapeHtml(v.shortTitle)}" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="border:0;max-width:100%;"></iframe>`,
|
`<iframe width="640" height="360" src="${escapeHtml(embedSrc)}" title="${escapeHtml(v.shortTitle)}" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen style="border:0;max-width:100%;"></iframe>`,
|
||||||
`<p><strong>${escapeHtml(desc)}</strong></p>`,
|
`<p><strong>${escapeHtml(desc)}</strong></p>`,
|
||||||
chapterHtml,
|
chapterHtml,
|
||||||
`<p><a href="${escapeHtml(v.youtubeUrl)}" target="_blank" rel="noopener">Das ganze Video auf YouTube ansehen</a></p>`,
|
`<p><a href="${escapeHtml(v.youtubeUrl)}" target="_blank" rel="noopener">Das ganze Video auf YouTube ansehen</a></p>`,
|
||||||
|
|||||||
Reference in New Issue
Block a user