Files
lidify/frontend/features/podcast/components/EpisodeList.tsx
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00

198 lines
9.9 KiB
TypeScript

"use client";
import { Play, Pause, Check, ArrowUpDown, CheckCircle } from "lucide-react";
import { cn } from "@/utils/cn";
import { Podcast, Episode } from "../types";
import { formatDuration, formatDate } from "../utils";
interface EpisodeListProps {
podcast: Podcast;
episodes: Episode[];
sortOrder: "newest" | "oldest";
onSortOrderChange: (order: "newest" | "oldest") => void;
isEpisodePlaying: (episodeId: string) => boolean;
isPlaying: boolean;
onPlayPause: (episode: Episode) => void;
onPlay: (episode: Episode) => void;
onMarkComplete?: (episodeId: string, duration: number) => void;
}
export function EpisodeList({
podcast,
episodes,
sortOrder,
onSortOrderChange,
isEpisodePlaying,
isPlaying,
onPlayPause,
onPlay,
onMarkComplete,
}: EpisodeListProps) {
return (
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-white">All Episodes</h2>
<button
onClick={() =>
onSortOrderChange(
sortOrder === "newest" ? "oldest" : "newest"
)
}
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 hover:bg-white/10 text-sm text-white/70 hover:text-white transition-all"
>
<ArrowUpDown className="w-4 h-4" />
{sortOrder === "newest" ? "Newest First" : "Oldest First"}
</button>
</div>
<div className="space-y-1">
{episodes.map((episode, index) => {
const isCurrentEpisode = isEpisodePlaying(episode.id);
const isInProgress =
episode.progress &&
!episode.progress.isFinished &&
episode.progress.currentTime > 0;
return (
<div
key={episode.id}
className={cn(
"group relative rounded-md transition-all",
isCurrentEpisode ? "bg-white/10" : "hover:bg-white/5"
)}
>
{/* Progress bar at the bottom */}
{episode.progress && episode.progress.progress > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full bg-[#ecb200]/60 transition-all"
style={{
width: `${episode.progress.progress}%`,
}}
/>
</div>
)}
<div
onClick={() => {
if (!isCurrentEpisode) {
onPlay(episode);
}
}}
className="flex items-center gap-4 px-3 py-3 cursor-pointer"
>
{/* Number / Play/Pause Icon */}
<div className="w-8 flex items-center justify-center shrink-0">
{episode.progress?.isFinished ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<>
<span
className={cn(
"text-sm",
isCurrentEpisode && isPlaying
? "hidden"
: "group-hover:hidden",
isCurrentEpisode
? "text-[#ecb200] font-bold"
: "text-white/40"
)}
>
{index + 1}
</span>
{isCurrentEpisode && isPlaying ? (
<Pause
className="w-4 h-4 text-[#ecb200] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onPlayPause(episode);
}}
/>
) : (
<Play
className={cn(
"w-4 h-4 cursor-pointer",
isCurrentEpisode
? "text-[#ecb200]"
: "text-white hidden group-hover:block"
)}
onClick={(e) => {
e.stopPropagation();
onPlayPause(episode);
}}
/>
)}
</>
)}
</div>
{/* Episode Info */}
<div className="flex-1 min-w-0">
<h3
className={cn(
"font-medium truncate text-sm",
isCurrentEpisode
? "text-[#ecb200]"
: "text-white"
)}
>
{episode.title}
</h3>
<div className="flex items-center gap-2 text-xs text-white/50 mt-0.5">
<span>{formatDate(episode.publishedAt)}</span>
{episode.season && (
<>
<span></span>
<span>S{episode.season}</span>
</>
)}
{episode.episodeNumber && (
<>
<span></span>
<span>E{episode.episodeNumber}</span>
</>
)}
{episode.progress?.isFinished && (
<>
<span></span>
<span className="text-green-400">Finished</span>
</>
)}
{isInProgress && episode.progress && (
<>
<span></span>
<span className="text-[#ecb200]">
{Math.floor(episode.progress.progress)}%
</span>
</>
)}
</div>
</div>
{/* Duration */}
<span className="text-xs text-white/40 shrink-0">
{formatDuration(episode.duration)}
</span>
{/* Complete Button - visible on hover for incomplete episodes */}
{onMarkComplete && !episode.progress?.isFinished && (
<button
onClick={(e) => {
e.stopPropagation();
onMarkComplete(episode.id, episode.duration);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity ml-2 p-1.5 rounded-full hover:bg-white/10"
title="Mark as complete"
>
<CheckCircle className="w-4 h-4 text-white/60 hover:text-green-400" />
</button>
)}
</div>
</div>
);
})}
</div>
</section>
);
}