14oqjtsnb: Play trending video answers as video with thumbnail fallback
This commit is contained in:
@@ -102,6 +102,7 @@ button:disabled { opacity: .5; cursor: not-allowed; }
|
|||||||
.avatar-sm { width: 28px; height: 28px; font-size: 11px; }
|
.avatar-sm { width: 28px; height: 28px; font-size: 11px; }
|
||||||
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
|
.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); }
|
||||||
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
.minnect-video { background: #000; border-radius: 0; }
|
||||||
.minnect-audio { width: 100%; height: 36px; }
|
.minnect-audio { width: 100%; height: 36px; }
|
||||||
@media (max-width: 959px) {
|
@media (max-width: 959px) {
|
||||||
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
|
.col-left { flex: 1 1 100%; position: static; padding-top: 8px; }
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { DiscoverMedia } from "@/lib/discover";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
media: DiscoverMedia | null;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
question: string | null;
|
||||||
|
who: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Video answers play inline; audio keeps the audio control; no url shows the thumbnail only. */
|
||||||
|
export function MinnectMedia({ media, thumbnailUrl, question, who }: Props) {
|
||||||
|
const [videoFailed, setVideoFailed] = useState(false);
|
||||||
|
|
||||||
|
const thumb = thumbnailUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img className="minnect-thumb" src={thumbnailUrl} alt={question ? `Thumbnail for: ${question}` : "Minnect thumbnail"} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (media?.kind === "video" && !videoFailed) {
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
className="minnect-thumb minnect-video"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
src={media.url}
|
||||||
|
poster={thumbnailUrl ?? undefined}
|
||||||
|
aria-label={`Play answer from ${who}`}
|
||||||
|
onError={() => setVideoFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{thumb}
|
||||||
|
{media?.kind === "audio" ? (
|
||||||
|
<audio className="minnect-audio" controls preload="none" src={media.url} aria-label={`Play answer from ${who}`} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DiscoverMinnect } from "@/lib/discover";
|
import type { DiscoverMinnect } from "@/lib/discover";
|
||||||
|
import { MinnectMedia } from "./MinnectMedia";
|
||||||
import { initials } from "@/lib/feed-model";
|
import { initials } from "@/lib/feed-model";
|
||||||
|
|
||||||
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
|
export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[] }) {
|
||||||
@@ -20,17 +21,11 @@ export function TrendingMinnectsRail({ minnects }: { minnects: DiscoverMinnect[]
|
|||||||
)}
|
)}
|
||||||
<strong className="rail-name">{who}</strong>
|
<strong className="rail-name">{who}</strong>
|
||||||
</div>
|
</div>
|
||||||
{m.thumbnailUrl ? (
|
<MinnectMedia media={m.media} thumbnailUrl={m.thumbnailUrl} question={m.question} who={who} />
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img className="minnect-thumb" src={m.thumbnailUrl} alt={m.question ? `Thumbnail for: ${m.question}` : "Minnect thumbnail"} />
|
|
||||||
) : null}
|
|
||||||
{m.question ? <p className="minnect-question">{m.question}</p> : null}
|
{m.question ? <p className="minnect-question">{m.question}</p> : null}
|
||||||
{m.views !== null ? (
|
{m.views !== null ? (
|
||||||
<span className="rail-meta">{m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}</span>
|
<span className="rail-meta">{m.views.toLocaleString("en-US")} {m.views === 1 ? "view" : "views"}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{m.audio.url ? (
|
|
||||||
<audio className="minnect-audio" controls preload="none" src={m.audio.url} aria-label={`Play answer from ${who}`} />
|
|
||||||
) : null}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+26
-10
@@ -17,16 +17,22 @@ export interface DiscoverExpert {
|
|||||||
location: { city: string | null; country: string | null };
|
location: { city: string | null; country: string | null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DiscoverMediaKind = "video" | "audio";
|
||||||
|
|
||||||
|
export interface DiscoverMedia {
|
||||||
|
kind: DiscoverMediaKind;
|
||||||
|
url: string;
|
||||||
|
duration: number | null;
|
||||||
|
waveform: number[] | string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscoverMinnect {
|
export interface DiscoverMinnect {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
question: string | null;
|
question: string | null;
|
||||||
views: number | null;
|
views: number | null;
|
||||||
thumbnailUrl: string | null;
|
thumbnailUrl: string | null;
|
||||||
audio: {
|
/** null when the answer has no playable url. */
|
||||||
url: string | null;
|
media: DiscoverMedia | null;
|
||||||
duration: number | null;
|
|
||||||
waveform: number[] | string | null;
|
|
||||||
};
|
|
||||||
expert: {
|
expert: {
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -92,6 +98,20 @@ function waveform(v: unknown): number[] | string | null {
|
|||||||
return str(v);
|
return str(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIDEO_TYPES = new Set(["MOV", "MP4", "M4V", "QUICKTIME", "MO4", "WEBM"]);
|
||||||
|
|
||||||
|
function mapMedia(answer: Rec): DiscoverMedia | null {
|
||||||
|
const url = str(answer.url);
|
||||||
|
if (!url) return null;
|
||||||
|
const type = (str(answer.file_type) ?? "").trim().toUpperCase();
|
||||||
|
return {
|
||||||
|
kind: VIDEO_TYPES.has(type) ? "video" : "audio",
|
||||||
|
url,
|
||||||
|
duration: num(answer.duration),
|
||||||
|
waveform: waveform(answer.waveform_data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function mapMinnect(raw: unknown): DiscoverMinnect | null {
|
export function mapMinnect(raw: unknown): DiscoverMinnect | null {
|
||||||
const r = rec(raw);
|
const r = rec(raw);
|
||||||
const mid = id(r.id);
|
const mid = id(r.id);
|
||||||
@@ -103,11 +123,7 @@ export function mapMinnect(raw: unknown): DiscoverMinnect | null {
|
|||||||
question: str(r.question_text),
|
question: str(r.question_text),
|
||||||
views: num(r.views),
|
views: num(r.views),
|
||||||
thumbnailUrl: str(rec(r.thumbnail).url),
|
thumbnailUrl: str(rec(r.thumbnail).url),
|
||||||
audio: {
|
media: mapMedia(answer),
|
||||||
url: str(answer.url),
|
|
||||||
duration: num(answer.duration),
|
|
||||||
waveform: waveform(answer.waveform_data),
|
|
||||||
},
|
|
||||||
expert: {
|
expert: {
|
||||||
slug: str(expert.slug),
|
slug: str(expert.slug),
|
||||||
displayName: displayName(expert),
|
displayName: displayName(expert),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const expert = (o: Partial<DiscoverExpert> = {}): DiscoverExpert => ({
|
|||||||
});
|
});
|
||||||
const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
|
const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
|
||||||
id: 9, question: "How do I price?", views: 1234, thumbnailUrl: "https://x/t.jpg",
|
id: 9, question: "How do I price?", views: 1234, thumbnailUrl: "https://x/t.jpg",
|
||||||
audio: { url: "https://x/a.mp3", duration: 30, waveform: null },
|
media: { kind: "audio", url: "https://x/a.mp3", duration: 30, waveform: null },
|
||||||
expert: { slug: "ann", displayName: "Ann Lee", pictureUrl: null, byline: null, rating: null }, ...o,
|
expert: { slug: "ann", displayName: "Ann Lee", pictureUrl: null, byline: null, rating: null }, ...o,
|
||||||
});
|
});
|
||||||
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
||||||
@@ -51,9 +51,28 @@ describe("TrendingMinnectsRail", () => {
|
|||||||
expect(out).toContain('src="https://x/a.mp3"');
|
expect(out).toContain('src="https://x/a.mp3"');
|
||||||
expect(out).toContain("t.jpg");
|
expect(out).toContain("t.jpg");
|
||||||
});
|
});
|
||||||
|
it("renders a video with poster for video answers, no audio element", () => {
|
||||||
|
const url = "https://valuentainment-quick-questions-prod.s3.amazonaws.com/a.mov?X-Amz-Signature=abc";
|
||||||
|
const out = html(createElement(TrendingMinnectsRail, {
|
||||||
|
minnects: [minnect({ media: { kind: "video", url, duration: 9, waveform: null } })],
|
||||||
|
}));
|
||||||
|
expect(out).toContain("<video");
|
||||||
|
expect(out).toContain("controls");
|
||||||
|
expect(out).toContain("preload=\"none\"");
|
||||||
|
expect(out).toContain("X-Amz-Signature=abc");
|
||||||
|
expect(out).toContain('poster="https://x/t.jpg"');
|
||||||
|
expect(out).not.toContain("<audio");
|
||||||
|
expect(out).not.toContain("autoPlay");
|
||||||
|
});
|
||||||
|
it("has no media element for a missing url but keeps the thumbnail", () => {
|
||||||
|
const out = html(createElement(TrendingMinnectsRail, { minnects: [minnect({ media: null })] }));
|
||||||
|
expect(out).not.toContain("<audio");
|
||||||
|
expect(out).not.toContain("<video");
|
||||||
|
expect(out).toContain("minnect-thumb");
|
||||||
|
});
|
||||||
it("has no audio element or thumbnail when those are missing", () => {
|
it("has no audio element or thumbnail when those are missing", () => {
|
||||||
const out = html(createElement(TrendingMinnectsRail, {
|
const out = html(createElement(TrendingMinnectsRail, {
|
||||||
minnects: [minnect({ audio: { url: null, duration: null, waveform: null }, thumbnailUrl: null, question: null, views: null })],
|
minnects: [minnect({ media: null, thumbnailUrl: null, question: null, views: null })],
|
||||||
}));
|
}));
|
||||||
expect(out).not.toContain("<audio");
|
expect(out).not.toContain("<audio");
|
||||||
expect(out).not.toContain("minnect-thumb");
|
expect(out).not.toContain("minnect-thumb");
|
||||||
|
|||||||
+17
-2
@@ -52,7 +52,7 @@ describe("GET /api/discover", () => {
|
|||||||
});
|
});
|
||||||
expect(json.trendingMinnects[0]).toEqual({
|
expect(json.trendingMinnects[0]).toEqual({
|
||||||
id: "c1", question: "How?", views: 10, thumbnailUrl: "https://img/t.jpg",
|
id: "c1", question: "How?", views: 10, thumbnailUrl: "https://img/t.jpg",
|
||||||
audio: { url: "https://a/1.mp3", duration: 42, waveform: [1, 2, 3] },
|
media: { kind: "audio", url: "https://a/1.mp3", duration: 42, waveform: [1, 2, 3] },
|
||||||
expert: { slug: "jane-doe", displayName: "Jane Doe", pictureUrl: "https://img/jane.jpg", byline: "Coach", rating: 4.9 },
|
expert: { slug: "jane-doe", displayName: "Jane Doe", pictureUrl: "https://img/jane.jpg", byline: "Coach", rating: 4.9 },
|
||||||
});
|
});
|
||||||
expect(json.categories).toEqual([
|
expect(json.categories).toEqual([
|
||||||
@@ -90,7 +90,7 @@ describe("GET /api/discover", () => {
|
|||||||
reviewsCount: null, available: null, location: { city: null, country: null },
|
reviewsCount: null, available: null, location: { city: null, country: null },
|
||||||
}]);
|
}]);
|
||||||
expect(out.trendingMinnects).toHaveLength(1);
|
expect(out.trendingMinnects).toHaveLength(1);
|
||||||
expect(out.trendingMinnects[0].audio).toEqual({ url: null, duration: null, waveform: null });
|
expect(out.trendingMinnects[0].media).toBeNull();
|
||||||
expect(out.categories).toEqual([{ id: 3, name: "A", icon: null, sortOrder: 0 }]);
|
expect(out.categories).toEqual([{ id: 3, name: "A", icon: null, sortOrder: 0 }]);
|
||||||
expect(mapDiscover(null)).toEqual({ featuredExperts: [], trendingMinnects: [], categories: [] });
|
expect(mapDiscover(null)).toEqual({ featuredExperts: [], trendingMinnects: [], categories: [] });
|
||||||
});
|
});
|
||||||
@@ -108,3 +108,18 @@ describe("GET /api/discover", () => {
|
|||||||
expect(b.res.status).toBe(502);
|
expect(b.res.status).toBe(502);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("trending media kind", () => {
|
||||||
|
const conv = (answer: unknown) => mapDiscover({ data: { trending_conversations: [{ id: 1, answer }] } }).trendingMinnects[0].media;
|
||||||
|
it.each(["MOV", "mp4", "M4V", "QuickTime", "MO4", "webm"])("maps %s to video and keeps the signed url", (t) => {
|
||||||
|
const url = "https://valuentainment-quick-questions-prod.s3.amazonaws.com/a.mov?X-Amz-Signature=abc";
|
||||||
|
expect(conv({ url, file_type: t, duration: 5 })).toEqual({ kind: "video", url, duration: 5, waveform: null });
|
||||||
|
});
|
||||||
|
it("maps other or missing file types to audio", () => {
|
||||||
|
expect(conv({ url: "https://a/x", file_type: "aac" })?.kind).toBe("audio");
|
||||||
|
expect(conv({ url: "https://a/x" })?.kind).toBe("audio");
|
||||||
|
});
|
||||||
|
it("has no media without a url", () => {
|
||||||
|
expect(conv({ file_type: "MOV" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user