Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/app/api/play/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,16 @@ export async function GET(request: Request) {

log.debug({ requestId, source, start }, 'planning transcode');

const plan = await planTranscode(source, start, request.signal);
let plan: Awaited<ReturnType<typeof planTranscode>>;
try {
plan = await planTranscode(source, start, request.signal);
} catch (error) {
log.error(
{ requestId, source, error: error instanceof Error ? error.message : String(error) },
'transcode planning failed',
);
return new Response('Could not prepare stream', { status: 502 });
}
if (!plan) {
log.error({ requestId, source }, 'could not prepare transcode plan');
return new Response('Could not prepare stream', { status: 502 });
Expand Down
19 changes: 19 additions & 0 deletions src/app/globals.css
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,32 @@
textarea {
touch-action: manipulation;
}
html {
background: var(--background);
}
body {
@apply bg-background text-foreground;
background-image:
radial-gradient(
circle at 80% -10%,
color-mix(in oklch, var(--primary) 12%, transparent),
transparent 34rem
),
linear-gradient(
180deg,
color-mix(in oklch, var(--background) 92%, var(--primary)),
var(--background) 28rem
);
background-attachment: fixed;
/* Disable the iOS tap flash without locking gestures. We deliberately
don't set overflow-x on the document: the rail scroller is the only
horizontal-scroll surface and it has its own overflow-x-auto. */
-webkit-tap-highlight-color: transparent;
}
::selection {
background: color-mix(in oklch, var(--primary) 35%, transparent);
color: var(--foreground);
}
}

@layer utilities {
Expand Down
32 changes: 27 additions & 5 deletions src/components/features/home/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ type Props = {
className?: string;
};

const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/;

export function Card({ item, onOpen, className }: Props) {
const title = titleFor(item);
const year = title.match(YEAR_PATTERN)?.[1];
const type = item.type?.toLowerCase() === 'series' ? 'Series' : 'Movie';

return (
<motion.button
type="button"
onClick={() => onOpen(item)}
className={cn(
'group block w-full shrink-0 overflow-hidden rounded-xl bg-secondary text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
'group block w-full shrink-0 overflow-hidden rounded-2xl border border-border/70 bg-card text-left shadow-sm transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none hover:shadow-xl hover:shadow-background/30',
className,
)}
// Entrance + hover lift. Transform/opacity only; reduced-motion users
Expand All @@ -46,13 +52,29 @@ export function Card({ item, onOpen, className }: Props) {
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
<span className="pointer-events-none absolute right-3 bottom-3 grid size-10 place-items-center rounded-full bg-primary text-primary-foreground opacity-0 transition-opacity group-hover:opacity-100">
<span className="pointer-events-none absolute right-3 bottom-3 grid size-10 place-items-center rounded-full bg-primary text-primary-foreground opacity-0 shadow-lg transition-all group-hover:scale-105 group-hover:opacity-100">
<Play className="size-4 fill-current" />
</span>
<div className="absolute top-3 left-3 flex max-w-[calc(100%-1.5rem)] flex-wrap gap-1.5">
<span className="rounded-md border border-border/60 bg-background/80 px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-foreground backdrop-blur">
{type}
</span>
{item.providerName && (
<span className="max-w-32 truncate rounded-md border border-primary/30 bg-primary/85 px-2 py-1 text-[10px] font-semibold text-primary-foreground shadow-sm">
{item.providerName}
</span>
)}
</div>
</div>
<div className="flex min-h-[5.5rem] flex-col gap-2 p-3">
<p className="line-clamp-2 text-sm font-semibold leading-5 text-foreground">{title}</p>
<div className="mt-auto flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
{year && <span>{year}</span>}
{year && <span aria-hidden="true">·</span>}
<span>{type}</span>
<span className="ml-auto text-primary">View details</span>
</div>
</div>
<p className="mt-3 line-clamp-2 min-h-[2.5rem] px-0.5 text-sm font-semibold">
{titleFor(item)}
</p>
</motion.button>
);
}
Expand Down
7 changes: 4 additions & 3 deletions src/components/features/home/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function Hero({ item, meta, providerName, inLibrary, onPlay, onToggleLibr
return (
<section
className={cn(
'relative mt-4 overflow-hidden rounded-2xl border border-border/60 sm:mt-5',
'relative mt-4 overflow-hidden rounded-[1.5rem] border border-border/60 bg-card shadow-2xl shadow-background/40 sm:mt-6',
'min-h-0 h-[clamp(320px,50svh,500px)]',
)}
>
Expand All @@ -53,8 +53,9 @@ export function Hero({ item, meta, providerName, inLibrary, onPlay, onToggleLibr
e.currentTarget.style.opacity = '0';
}}
/>
<div className="absolute inset-0 bg-gradient-to-r from-background via-background/70 to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent" />
<div className="absolute inset-0 bg-gradient-to-r from-background via-background/80 to-background/10" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/20 to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-px bg-primary/60" />

{/* Content sits at the bottom on phones, centered-left on larger screens. */}
<motion.div
Expand Down
7 changes: 1 addition & 6 deletions src/components/features/home/Rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,7 @@ function RailBase({ title, items, onOpen, loading }: Props) {
// The loading branch above returns a separate subtree, so this section
// mounts fresh when the skeleton swaps out — the fade-in covers the
// skeleton -> content jump.
<motion.section
initial="hidden"
animate="visible"
variants={fadeIn}
className="mt-8 min-w-0 sm:mt-10"
>
<motion.section initial="hidden" animate="visible" variants={fadeIn} className="mt-0 min-w-0">
<div className="mb-3 flex items-center justify-between sm:mb-4">
<h2 className="text-base font-semibold tracking-tight sm:text-lg">{title}</h2>
<div className="hidden items-center gap-1 sm:flex">
Expand Down
6 changes: 3 additions & 3 deletions src/components/features/player/DetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ export function DetailModal({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 18 }}
transition={{ duration: DURATIONS.base, ease: EASE }}
className="flex h-full w-full flex-col overflow-hidden overscroll-contain border-border bg-card sm:h-auto sm:max-h-[90vh] sm:max-w-2xl sm:overflow-auto sm:rounded-2xl sm:border"
className="flex h-full w-full flex-col overflow-hidden overscroll-contain border-border/70 bg-card shadow-2xl sm:h-auto sm:max-h-[92vh] sm:max-w-4xl sm:flex-row sm:overflow-hidden sm:rounded-3xl sm:border"
>
{/* Backdrop */}
<div className="relative h-56 w-full shrink-0 sm:h-64">
<div className="relative h-64 w-full shrink-0 sm:h-full sm:min-h-[34rem] sm:w-[40%]">
{backdrop || poster ? (
// biome-ignore lint/performance/noImgElement: images are served unoptimized (next.config `images.unoptimized`), so next/image adds no value here.
<img
Expand Down Expand Up @@ -127,7 +127,7 @@ export function DetailModal({
</Button>
</div>

<div className="-mt-10 flex flex-1 flex-col gap-4 overflow-y-auto p-5 sm:gap-5 sm:overflow-visible sm:p-6">
<div className="flex min-w-0 flex-1 flex-col gap-5 overflow-y-auto p-5 sm:gap-6 sm:p-8">
<div className="flex items-end justify-between gap-4">
<div className="min-w-0">
{logo && !logoFailed ? (
Expand Down
69 changes: 23 additions & 46 deletions src/components/features/player/PlayerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
useMediaState,
} from '@vidstack/react';
import { DefaultVideoLayout, defaultLayoutIcons } from '@vidstack/react/player/layouts/default';
import { ChevronLeft, ListEnd, Loader2, Play, RotateCcw } from 'lucide-react';
import { ChevronLeft, Play, RotateCcw } from 'lucide-react';
import { motion } from 'motion/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import '@vidstack/react/player/styles/default/theme.css';
Expand All @@ -32,7 +32,6 @@ type Props = {
loading: boolean;
errorMessage?: string;
defaultPlaybackRate?: number;
defaultAutoAdvance?: boolean;
provider: string;
onClose: () => void;
onSelectEpisode: (item: Episode) => void;
Expand Down Expand Up @@ -267,15 +266,13 @@ export function PlayerModal({
loading,
errorMessage,
defaultPlaybackRate,
defaultAutoAdvance,
provider,
onClose,
onSelectEpisode,
}: Props) {
const progress = useProgress(provider);
const { rate, setRate } = usePlaybackRate(defaultPlaybackRate);

const [autoAdvance, setAutoAdvance] = useState(defaultAutoAdvance ?? true);
const [stalledMessage, setStalledMessage] = useState<string | null>(null);
const [resumeOffered, setResumeOffered] = useState(false);
const actionsRef = useRef<PlayerActions | null>(null);
Expand All @@ -284,21 +281,22 @@ export function PlayerModal({
const sources = resolved.kind === 'sources' ? resolved.sources : [];
const [sourceIndex, setSourceIndex] = useState(0);
const source = sources[sourceIndex]?.link;
const sourceType = sources[sourceIndex]?.type;

// A new stream (new title, episode, or quality set) always starts on the
// first source; otherwise an index left over from a longer list would
// point past the end and the player would show "no stream".
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-runs when the stream changes even though the body only uses the stable setter.
useEffect(() => {
setSourceIndex(0);
setStalledMessage(null);
setResumeOffered(false);
}, [stream]);

const savedPosition = source ? progress.get(item.link, activeEpisode)?.position : undefined;

// Which renderer serves the current source: HLS via Vidstack's internal
// hls.js, a natively playable MP4, or the ffmpeg transcode proxy (MKV et al.).
const kind = classifySource(source ?? '', sourceType);
const kind = classifySource(source ?? '', undefined);

Comment on lines 297 to 300
// The URL the player actually fetches. HLS + native go through the
// server-side stream proxy (Referer/CORS safe, manifest rewritten);
Expand Down Expand Up @@ -374,6 +372,12 @@ export function PlayerModal({
// Transcode errors surface through the MSE hook instead —
// the placeholder src can race a not-yet-attached MediaSource.
if (kind === 'transcode') return;
if (sourceIndex < sources.length - 1) {
setStalledMessage(null);
setResumeOffered(false);
setSourceIndex((index) => index + 1);
return;
}
setStalledMessage(
detail?.message || 'This source failed to load. Try another source.',
);
Expand All @@ -391,7 +395,7 @@ export function PlayerModal({
savedPosition={savedPosition}
rate={rate}
setRate={setRate}
autoAdvance={autoAdvance}
autoAdvance={true}
stalledMessage={stalledMessage}
setStalledMessage={setStalledMessage}
resumeOffered={resumeOffered}
Expand Down Expand Up @@ -427,45 +431,18 @@ export function PlayerModal({
</div>
</div>

<div className="flex flex-wrap items-center justify-between gap-3">
<div className="-mx-1 flex flex-wrap gap-2 overflow-x-auto px-1 sm:mx-0 sm:overflow-visible">
{sources.length > 1 &&
sources.map((s, i) => (
<Button
key={s.link}
size="sm"
variant={i === sourceIndex ? 'default' : 'outline'}
onClick={() => setSourceIndex(i)}
className="touch-target shrink-0"
>
{s.server || `Source ${i + 1}`}
</Button>
))}
{source && (
<Button
size="sm"
variant="ghost"
onClick={() => actionsRef.current?.restart()}
className="touch-target shrink-0"
>
<RotateCcw className="size-3.5" /> Restart
</Button>
)}
{source && (
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
onClick={() => setAutoAdvance((v) => !v)}
className="touch-target shrink-0"
variant="ghost"
onClick={() => actionsRef.current?.restart()}
className="touch-target"
>
<ListEnd className="size-3.5" />
Auto-advance: {autoAdvance ? 'On' : 'Off'}
<RotateCcw className="size-3.5" /> Start over
</Button>
</div>
<span className="hidden items-center gap-2 text-xs text-muted-foreground sm:inline-flex">
<Loader2 className="size-3.5" /> Use the player controls for speed, captions and
fullscreen
</span>
</div>
)}

{showResumePrompt && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-primary/40 bg-primary/10 p-3 text-sm">
Expand Down Expand Up @@ -496,11 +473,11 @@ export function PlayerModal({
<div className="mt-2 grid gap-4 lg:mt-6 lg:grid-cols-[1fr_280px] lg:gap-6">
<div className="min-w-0">
<h1 className="text-xl font-semibold sm:text-2xl">{titleFor(item)}</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{kind === 'transcode'
? 'Streaming through the transcoding proxy — playback may start a few seconds after the buffers fill.'
: 'Direct playback from the selected provider. Episodes advance automatically when one ends.'}
</p>
{episodes.length === 0 && loading && (
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Getting this ready to watch…
</p>
)}
</div>
<EpisodeList
episodes={episodes}
Expand Down
38 changes: 35 additions & 3 deletions src/components/features/search/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ export const Results = memo(function Results({
onHistoryClear,
onHistorySearch,
}: Props) {
const providerGroups = Array.from(
results.reduce((groups, item) => {
const key = item.providerId ?? 'catalog';
const current = groups.get(key) ?? {
name: item.providerName ?? 'All providers',
items: [] as Media[],
};
current.items.push(item);
groups.set(key, current);
return groups;
}, new Map<string, { name: string; items: Media[] }>()),
);

return (
<section className="py-6 sm:py-10">
<div className="mb-6 sm:mb-8">
Expand Down Expand Up @@ -109,9 +122,28 @@ export const Results = memo(function Results({
<p className="text-sm text-muted-foreground">
{results.length} {results.length === 1 ? 'title' : 'titles'} found
</p>
<div className="grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 sm:gap-x-4 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{results.map((x) => (
<MemoCard key={x.link} item={x} onOpen={onOpen} />
<div className="flex flex-col gap-8">
{providerGroups.map(([providerId, group]) => (
<section key={providerId} aria-labelledby={`provider-${providerId}`}>
<div className="mb-3 flex items-end justify-between gap-3 border-b border-border/60 pb-3">
<div>
<h2 id={`provider-${providerId}`} className="text-base font-semibold">
{group.name}
</h2>
<p className="mt-1 text-xs text-muted-foreground">
{group.items.length} matching titles
</p>
</div>
<span className="rounded-full border border-primary/30 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-primary">
Provider catalog
</span>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-6 sm:grid-cols-3 sm:gap-x-4 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{group.items.map((x) => (
<MemoCard key={`${providerId}-${x.link}`} item={x} onOpen={onOpen} />
))}
</div>
</section>
))}
</div>
</div>
Expand Down
Loading