- Multi-provider TTS service (OpenAI, Piper, macOS say) - Supabase Storage integration for audio files - RSS 2.0 feed with iTunes extensions for podcast distribution - Web audio player at /podcast page - Integration with daily digest workflow - Manual TTS generation script - Complete documentation in PODCAST_SETUP.md
353 lines
15 KiB
TypeScript
353 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useRef } from "react";
|
||
import Head from "next/head";
|
||
import Link from "next/link";
|
||
import { format } from "date-fns";
|
||
import { PodcastEpisode, DEFAULT_CONFIG } from "@/lib/podcast";
|
||
|
||
interface EpisodeWithAudio extends PodcastEpisode {
|
||
audioUrl: string;
|
||
audioDuration: number;
|
||
}
|
||
|
||
function formatDuration(seconds: number): string {
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = seconds % 60;
|
||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||
}
|
||
|
||
export default function PodcastPage() {
|
||
const [episodes, setEpisodes] = useState<EpisodeWithAudio[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [currentEpisode, setCurrentEpisode] = useState<EpisodeWithAudio | null>(null);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [progress, setProgress] = useState(0);
|
||
const [duration, setDuration] = useState(0);
|
||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
fetchEpisodes();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (currentEpisode && audioRef.current) {
|
||
audioRef.current.play();
|
||
setIsPlaying(true);
|
||
}
|
||
}, [currentEpisode]);
|
||
|
||
async function fetchEpisodes() {
|
||
try {
|
||
const res = await fetch("/api/messages");
|
||
const data = await res.json();
|
||
|
||
// Filter to only episodes with audio
|
||
const episodesWithAudio = (data || [])
|
||
.filter((m: any) => m.audio_url)
|
||
.map((m: any) => ({
|
||
id: m.id,
|
||
title: extractTitle(m.content),
|
||
description: extractExcerpt(m.content),
|
||
content: m.content,
|
||
date: m.date,
|
||
timestamp: m.timestamp,
|
||
audioUrl: m.audio_url,
|
||
audioDuration: m.audio_duration || 300,
|
||
tags: m.tags || [],
|
||
}))
|
||
.sort((a: any, b: any) => b.timestamp - a.timestamp);
|
||
|
||
setEpisodes(episodesWithAudio);
|
||
} catch (err) {
|
||
console.error("Failed to fetch episodes:", err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
function extractTitle(content: string): string {
|
||
const lines = content.split("\n");
|
||
const titleLine = lines.find((l) => l.startsWith("# ") || l.startsWith("## "));
|
||
return titleLine?.replace(/#{1,2}\s/, "").trim() || "Daily Digest";
|
||
}
|
||
|
||
function extractExcerpt(content: string, maxLength: number = 150): string {
|
||
const plainText = content
|
||
.replace(/#{1,6}\s/g, "")
|
||
.replace(/(\*\*|__|\*|_)/g, "")
|
||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||
.replace(/```[\s\S]*?```/g, "")
|
||
.replace(/`([^`]+)`/g, " $1 ")
|
||
.replace(/\n+/g, " ")
|
||
.trim();
|
||
|
||
if (plainText.length <= maxLength) return plainText;
|
||
return plainText.substring(0, maxLength).trim() + "...";
|
||
}
|
||
|
||
function handlePlay(episode: EpisodeWithAudio) {
|
||
if (currentEpisode?.id === episode.id) {
|
||
togglePlay();
|
||
} else {
|
||
setCurrentEpisode(episode);
|
||
setProgress(0);
|
||
}
|
||
}
|
||
|
||
function togglePlay() {
|
||
if (audioRef.current) {
|
||
if (isPlaying) {
|
||
audioRef.current.pause();
|
||
} else {
|
||
audioRef.current.play();
|
||
}
|
||
setIsPlaying(!isPlaying);
|
||
}
|
||
}
|
||
|
||
function handleTimeUpdate() {
|
||
if (audioRef.current) {
|
||
setProgress(audioRef.current.currentTime);
|
||
setDuration(audioRef.current.duration || currentEpisode?.audioDuration || 0);
|
||
}
|
||
}
|
||
|
||
function handleSeek(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const newTime = parseFloat(e.target.value);
|
||
if (audioRef.current) {
|
||
audioRef.current.currentTime = newTime;
|
||
setProgress(newTime);
|
||
}
|
||
}
|
||
|
||
function handleEnded() {
|
||
setIsPlaying(false);
|
||
setProgress(0);
|
||
}
|
||
|
||
const rssUrl = "https://blog-backup-two.vercel.app/api/podcast/rss";
|
||
|
||
return (
|
||
<>
|
||
<Head>
|
||
<title>Podcast | OpenClaw Daily Digest</title>
|
||
<meta name="description" content={DEFAULT_CONFIG.description} />
|
||
</Head>
|
||
|
||
<div className="min-h-screen bg-white text-gray-900 dark:bg-slate-950 dark:text-slate-100">
|
||
{/* Header */}
|
||
<header className="border-b border-gray-200 sticky top-0 bg-white/95 backdrop-blur z-50 dark:border-slate-800 dark:bg-slate-950/95">
|
||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div className="flex items-center justify-between h-16">
|
||
<Link href="/" className="flex items-center gap-2">
|
||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-bold">
|
||
🎧
|
||
</div>
|
||
<span className="font-bold text-xl">Daily Digest Podcast</span>
|
||
</Link>
|
||
|
||
<nav className="flex items-center gap-4">
|
||
<Link href="/" className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
|
||
Blog
|
||
</Link>
|
||
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900 dark:text-slate-300 dark:hover:text-slate-100">
|
||
Admin
|
||
</Link>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
{/* Podcast Header */}
|
||
<div className="bg-gradient-to-br from-blue-600 to-purple-600 rounded-2xl p-8 text-white mb-8">
|
||
<div className="flex flex-col md:flex-row gap-6">
|
||
<div className="w-32 h-32 bg-white/20 rounded-xl flex items-center justify-center text-5xl">
|
||
🎙️
|
||
</div>
|
||
<div className="flex-1">
|
||
<h1 className="text-3xl font-bold mb-2">{DEFAULT_CONFIG.title}</h1>
|
||
<p className="text-blue-100 mb-4">{DEFAULT_CONFIG.description}</p>
|
||
<div className="flex flex-wrap gap-3">
|
||
<a
|
||
href={rssUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
|
||
>
|
||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M6.503 20.752c0 2.07-1.678 3.748-3.75 3.748S-.997 22.82-.997 20.75c0-2.07 1.68-3.748 3.75-3.748s3.753 1.678 3.753 3.748zm10.5-10.12c0 2.07-1.678 3.75-3.75 3.75s-3.75-1.68-3.75-3.75c0-2.07 1.678-3.75 3.75-3.75s3.75 1.68 3.75 3.75zm-1.5 0c0-1.24-1.01-2.25-2.25-2.25s-2.25 1.01-2.25 2.25 1.01 2.25 2.25 2.25 2.25-1.01 2.25-2.25zm4.5 10.12c0 2.07-1.678 3.748-3.75 3.748s-3.75-1.678-3.75-3.748c0-2.07 1.678-3.748 3.75-3.748s3.75 1.678 3.75 3.748zm1.5 0c0-2.898-2.355-5.25-5.25-5.25S15 17.852 15 20.75c0 2.898 2.355 5.25 5.25 5.25s5.25-2.352 5.25-5.25zm-7.5-10.12c0 2.898-2.355 5.25-5.25 5.25S3 13.61 3 10.713c0-2.9 2.355-5.25 5.25-5.25s5.25 2.35 5.25 5.25zm1.5 0c0-3.73-3.02-6.75-6.75-6.75S-3 6.983-3 10.713c0 3.73 3.02 6.75 6.75 6.75s6.75-3.02 6.75-6.75z"/>
|
||
</svg>
|
||
RSS Feed
|
||
</a>
|
||
<a
|
||
href={`https://podcasts.apple.com/?feedUrl=${encodeURIComponent(rssUrl)}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
|
||
>
|
||
🍎 Apple Podcasts
|
||
</a>
|
||
<a
|
||
href={`https://open.spotify.com/?feedUrl=${encodeURIComponent(rssUrl)}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
|
||
>
|
||
🎵 Spotify
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Now Playing */}
|
||
{currentEpisode && (
|
||
<div className="bg-gray-50 rounded-xl p-4 mb-8 border border-gray-200 sticky top-20 z-40 dark:bg-slate-900 dark:border-slate-700">
|
||
<div className="flex items-center gap-4">
|
||
<button
|
||
onClick={togglePlay}
|
||
className="w-12 h-12 bg-blue-600 hover:bg-blue-700 rounded-full flex items-center justify-center text-white transition-colors"
|
||
>
|
||
{isPlaying ? (
|
||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||
</svg>
|
||
) : (
|
||
<svg className="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-semibold text-gray-900 dark:text-slate-100 truncate">
|
||
{currentEpisode.title}
|
||
</h3>
|
||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||
{format(new Date(currentEpisode.date), "MMMM d, yyyy")}
|
||
</p>
|
||
</div>
|
||
<div className="hidden sm:block text-sm text-gray-500 dark:text-slate-400">
|
||
{formatDuration(Math.floor(progress))} / {formatDuration(currentEpisode.audioDuration)}
|
||
</div>
|
||
</div>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={duration || currentEpisode.audioDuration}
|
||
value={progress}
|
||
onChange={handleSeek}
|
||
className="w-full mt-3 accent-blue-600"
|
||
/>
|
||
<audio
|
||
ref={audioRef}
|
||
src={currentEpisode.audioUrl}
|
||
onTimeUpdate={handleTimeUpdate}
|
||
onEnded={handleEnded}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Episode List */}
|
||
<div className="space-y-4">
|
||
<h2 className="text-xl font-semibold mb-4">Episodes</h2>
|
||
|
||
{loading ? (
|
||
<div className="text-center py-12">
|
||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto"/>
|
||
</div>
|
||
) : episodes.length === 0 ? (
|
||
<div className="text-center py-12 bg-gray-50 rounded-xl dark:bg-slate-900">
|
||
<p className="text-gray-500 dark:text-slate-400">No podcast episodes yet.</p>
|
||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-2">
|
||
Episodes are generated when new digests are posted with audio enabled.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
episodes.map((episode) => (
|
||
<div
|
||
key={episode.id}
|
||
className={`p-4 rounded-xl border transition-colors ${
|
||
currentEpisode?.id === episode.id
|
||
? "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800"
|
||
: "bg-white border-gray-200 hover:border-gray-300 dark:bg-slate-900 dark:border-slate-800 dark:hover:border-slate-700"
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-4">
|
||
<button
|
||
onClick={() => handlePlay(episode)}
|
||
className="w-10 h-10 bg-gray-100 hover:bg-gray-200 rounded-full flex items-center justify-center text-gray-700 transition-colors flex-shrink-0 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300"
|
||
>
|
||
{currentEpisode?.id === episode.id && isPlaying ? (
|
||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||
</svg>
|
||
) : (
|
||
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M8 5v14l11-7z"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-1">
|
||
<Link href={`/?post=${episode.id}`} className="hover:text-blue-600 dark:hover:text-blue-400">
|
||
{episode.title}
|
||
</Link>
|
||
</h3>
|
||
<p className="text-sm text-gray-600 dark:text-slate-400 line-clamp-2 mb-2">
|
||
{episode.description}
|
||
</p>
|
||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-500">
|
||
<span>{format(new Date(episode.date), "MMMM d, yyyy")}</span>
|
||
<span>·</span>
|
||
<span>{formatDuration(episode.audioDuration)}</span>
|
||
{episode.tags && episode.tags.length > 0 && (
|
||
<>
|
||
<span>·</span>
|
||
<span className="text-blue-600 dark:text-blue-400">
|
||
{episode.tags.slice(0, 2).join(", ")}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Subscribe Section */}
|
||
<div className="mt-12 p-6 bg-gray-50 rounded-xl border border-gray-200 dark:bg-slate-900 dark:border-slate-800">
|
||
<h3 className="font-semibold text-gray-900 dark:text-slate-100 mb-2">Subscribe to the Podcast</h3>
|
||
<p className="text-sm text-gray-600 dark:text-slate-400 mb-4">
|
||
Get the latest tech digest delivered to your favorite podcast app.
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<code className="px-3 py-1.5 bg-white border border-gray-300 rounded text-sm font-mono text-gray-700 dark:bg-slate-950 dark:border-slate-700 dark:text-slate-300">
|
||
{rssUrl}
|
||
</code>
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(rssUrl)}
|
||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium transition-colors"
|
||
>
|
||
Copy RSS URL
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Footer */}
|
||
<footer className="border-t border-gray-200 mt-16 dark:border-slate-800">
|
||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
<p className="text-center text-gray-500 dark:text-slate-400 text-sm">
|
||
© {new Date().getFullYear()} {DEFAULT_CONFIG.author}
|
||
</p>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|