blog-backup/src/app/podcast/page.tsx
OpenClaw Bot 9720390e1a Add podcast feature with TTS, RSS feed, and web player
- 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
2026-02-23 20:15:27 -06:00

353 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
</>
);
}