feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
|
import { lazy, Suspense, useEffect, useRef, type CSSProperties } from "react";
|
2026-07-05 02:32:00 +02:00
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
2026-07-05 03:29:20 +02:00
|
|
|
|
import { ArrowLeft } from "lucide-react";
|
|
|
|
|
|
import { api, type PlexCard } from "../lib/api";
|
2026-07-05 02:32:00 +02:00
|
|
|
|
import { useDebounced } from "../lib/useDebounced";
|
2026-07-05 03:29:20 +02:00
|
|
|
|
import { useHistorySubview } from "../lib/history";
|
2026-07-05 02:32:00 +02:00
|
|
|
|
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
|
|
|
|
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
|
|
|
|
|
|
|
|
|
|
|
type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string };
|
|
|
|
|
|
|
2026-07-05 03:29:20 +02:00
|
|
|
|
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
|
|
|
|
|
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
|
|
|
|
|
// search term comes from the shared top Header search box (q). A show drill-down rides history
|
|
|
|
|
|
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
|
|
|
|
|
|
// for now. Rendered by App for page==="plex" (its own nav module).
|
2026-07-05 02:32:00 +02:00
|
|
|
|
|
2026-07-05 03:29:20 +02:00
|
|
|
|
type Props = { q: string; library: string; show: string; sort: string };
|
2026-07-05 02:32:00 +02:00
|
|
|
|
|
|
|
|
|
|
const PAGE = 40;
|
|
|
|
|
|
|
2026-07-05 03:29:20 +02:00
|
|
|
|
function dur(n?: number | null): string {
|
2026-07-05 02:32:00 +02:00
|
|
|
|
if (!n) return "";
|
|
|
|
|
|
const h = Math.floor(n / 3600);
|
|
|
|
|
|
const m = Math.floor((n % 3600) / 60);
|
|
|
|
|
|
return h ? `${h}h ${m}m` : `${m}m`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 03:29:20 +02:00
|
|
|
|
export default function PlexBrowse({ q, library, show, sort }: Props) {
|
2026-07-05 02:32:00 +02:00
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
const dq = useDebounced(q.trim(), 350);
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
|
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
2026-07-05 02:32:00 +02:00
|
|
|
|
|
|
|
|
|
|
const browseQ = useInfiniteQuery({
|
2026-07-05 03:29:20 +02:00
|
|
|
|
queryKey: ["plex-browse", library, dq, sort, show],
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
|
enabled: !!library && sub.view.kind === "grid",
|
2026-07-05 02:32:00 +02:00
|
|
|
|
initialPageParam: 0,
|
|
|
|
|
|
queryFn: ({ pageParam }) =>
|
2026-07-05 03:29:20 +02:00
|
|
|
|
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
|
2026-07-05 02:32:00 +02:00
|
|
|
|
getNextPageParam: (last) => {
|
|
|
|
|
|
const seen = last.offset + last.items.length;
|
|
|
|
|
|
return seen < last.total ? seen : undefined;
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
|
|
|
|
|
|
const total = browseQ.data?.pages[0]?.total ?? 0;
|
|
|
|
|
|
|
2026-07-05 03:29:20 +02:00
|
|
|
|
// Infinite scroll: auto-load the next page when the sentinel scrolls into view.
|
|
|
|
|
|
const sentinel = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = sentinel.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const io = new IntersectionObserver(
|
|
|
|
|
|
(entries) => {
|
|
|
|
|
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
|
|
|
|
|
|
},
|
|
|
|
|
|
{ rootMargin: "600px" },
|
|
|
|
|
|
);
|
|
|
|
|
|
io.observe(el);
|
|
|
|
|
|
return () => io.disconnect();
|
|
|
|
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
|
|
|
2026-07-05 02:32:00 +02:00
|
|
|
|
function onCard(card: PlexCard) {
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
|
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
|
|
|
|
|
else sub.open({ kind: "player", id: card.id });
|
2026-07-05 02:32:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
feat(plex): P2 player — rich full-page HLS/direct player + watch-state
- backend: GET /api/plex/item/{rk} detail (metadata, cast + intro/credit markers
from Plex, episode prev/next, per-user resume+status); POST /item/{rk}/progress
(resume checkpoint, near-end→watched) + /state (new|watched|hidden).
- frontend PlexPlayer.tsx (lazy, pulls hls.js): full-page player over the Plex
module. Direct files stream raw <video>; remux via hls.js on the seek-restart
session — custom controls map the ABSOLUTE timeline over the session offset, and a
seek beyond the loaded region restarts the session at the target. Play/pause/resume
/restart, ±10s seek, prev/next episode (Shift+←/→), volume, windowed/fullscreen,
full keyboard, Skip intro/Skip credits from markers (shaded on the seekbar),
auto-advance + watched-on-end, resume from saved position, 10s progress checkpoints.
- PlexBrowse: card/episode click opens the player as a history subview (browser Back
returns to the grid/show). api client (plexItem/plexSession/plexProgress/plexSetState)
+ types; plex.player.* i18n en/hu/de. hls.js@^1.6.16.
- Verified over HTTP: detail w/ markers+cast+episode-nav, progress persist. Video
playback itself is pending browser UAT. Subtitle/audio track SELECTION deferred.
2026-07-05 04:28:00 +02:00
|
|
|
|
if (sub.view.kind === "player") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
|
|
|
|
|
|
<PlexPlayer itemId={sub.view.id} onClose={sub.back} />
|
|
|
|
|
|
</Suspense>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (sub.view.kind === "show") {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<PlexShowView
|
|
|
|
|
|
showId={sub.view.id}
|
|
|
|
|
|
onBack={sub.back}
|
|
|
|
|
|
onPlay={(ep) => sub.open({ kind: "player", id: ep.id })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-07-05 02:32:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="p-4 max-w-[1600px] mx-auto">
|
|
|
|
|
|
<p className="text-xs text-muted mb-3">
|
2026-07-05 03:29:20 +02:00
|
|
|
|
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
|
2026-07-05 02:32:00 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{browseQ.isLoading ? (
|
|
|
|
|
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
|
|
|
|
|
) : items.length === 0 ? (
|
2026-07-05 03:29:20 +02:00
|
|
|
|
<p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
|
2026-07-05 02:32:00 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
|
|
|
|
|
{items.map((c) => (
|
|
|
|
|
|
<PlexPosterCard key={c.id} card={c} onClick={() => onCard(c)} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-05 03:29:20 +02:00
|
|
|
|
<div ref={sentinel} className="h-8" />
|
|
|
|
|
|
{isFetchingNextPage && <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>}
|
2026-07-05 02:32:00 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const PLAYABLE_TINT: Record<string, string> = {
|
|
|
|
|
|
direct: "bg-emerald-500",
|
|
|
|
|
|
remux: "bg-amber-500",
|
|
|
|
|
|
transcode: "bg-orange-600",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function PlexPosterCard({ card, onClick }: { card: PlexCard; onClick: () => void }) {
|
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
|
|
|
|
|
const pct =
|
|
|
|
|
|
inProgress && card.duration_seconds
|
|
|
|
|
|
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
|
|
|
|
|
|
: 0;
|
|
|
|
|
|
return (
|
2026-07-05 03:29:20 +02:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
className="group text-left"
|
|
|
|
|
|
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
|
|
|
|
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
|
|
|
|
|
>
|
2026-07-05 02:32:00 +02:00
|
|
|
|
<div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={card.thumb}
|
|
|
|
|
|
alt=""
|
|
|
|
|
|
loading="lazy"
|
2026-07-05 03:29:20 +02:00
|
|
|
|
decoding="async"
|
2026-07-05 02:32:00 +02:00
|
|
|
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{card.status === "watched" && (
|
|
|
|
|
|
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
|
|
|
|
|
{t("plex.watched")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.type === "show" && card.season_count != null && (
|
|
|
|
|
|
<span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
|
|
|
|
|
|
{t("plex.seasons", { count: card.season_count })}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{card.playable && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
|
|
|
|
|
title={t(`plex.playable.${card.playable}`)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{pct > 0 && (
|
|
|
|
|
|
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
|
|
|
|
|
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{card.title}</div>
|
2026-07-05 03:29:20 +02:00
|
|
|
|
<div className="text-xs text-muted">{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}</div>
|
2026-07-05 02:32:00 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function PlexShowView({
|
|
|
|
|
|
showId,
|
|
|
|
|
|
onBack,
|
|
|
|
|
|
onPlay,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
showId: string;
|
|
|
|
|
|
onBack: () => void;
|
|
|
|
|
|
onPlay: (c: PlexCard) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) });
|
|
|
|
|
|
const d = q.data;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="p-4 max-w-[1200px] mx-auto">
|
2026-07-05 03:29:20 +02:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={onBack}
|
|
|
|
|
|
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
|
|
|
|
|
|
>
|
|
|
|
|
|
<ArrowLeft className="w-4 h-4" />
|
|
|
|
|
|
{t("plex.backToLibrary")}
|
|
|
|
|
|
</button>
|
2026-07-05 02:32:00 +02:00
|
|
|
|
|
|
|
|
|
|
{q.isLoading || !d ? (
|
|
|
|
|
|
<p className="text-muted text-sm">{t("plex.loading")}</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex gap-4 mb-6">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={d.show.thumb}
|
|
|
|
|
|
alt=""
|
|
|
|
|
|
className="w-32 sm:w-40 aspect-[2/3] object-cover rounded-xl border border-border shrink-0"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
|
<h1 className="text-xl font-semibold">{d.show.title}</h1>
|
|
|
|
|
|
{d.show.year && <p className="text-sm text-muted">{d.show.year}</p>}
|
|
|
|
|
|
{d.show.summary && (
|
|
|
|
|
|
<p className="text-sm text-muted mt-2 line-clamp-6 leading-relaxed">{d.show.summary}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{d.seasons.map((se) => (
|
|
|
|
|
|
<div key={se.id} className="mb-6">
|
|
|
|
|
|
<h2 className="text-sm font-semibold mb-2">{se.title}</h2>
|
|
|
|
|
|
<div className="flex flex-col divide-y divide-border/60">
|
|
|
|
|
|
{se.episodes.map((ep) => {
|
|
|
|
|
|
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={ep.id}
|
|
|
|
|
|
onClick={() => onPlay(ep)}
|
|
|
|
|
|
className="flex items-center gap-3 py-2 text-left group"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="relative w-28 aspect-video rounded-lg overflow-hidden bg-card border border-border shrink-0">
|
|
|
|
|
|
<img src={ep.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
|
|
|
|
|
{ep.status === "watched" && (
|
|
|
|
|
|
<span className="absolute top-1 right-1 text-[9px] bg-black/70 text-white px-1 rounded">
|
|
|
|
|
|
✓
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{inProgress && <div className="absolute bottom-0 inset-x-0 h-0.5 bg-accent" />}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<div className="text-sm font-medium truncate">
|
|
|
|
|
|
<span className="text-muted mr-1.5">{ep.episode_number}.</span>
|
|
|
|
|
|
{ep.title}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{ep.summary && (
|
|
|
|
|
|
<div className="text-xs text-muted line-clamp-2 mt-0.5">{ep.summary}</div>
|
|
|
|
|
|
)}
|
2026-07-05 03:29:20 +02:00
|
|
|
|
<div className="text-[11px] text-muted mt-0.5">{dur(ep.duration_seconds)}</div>
|
2026-07-05 02:32:00 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|