diff --git a/src/App.tsx b/src/App.tsx index 011835b..b9680ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,8 @@ import { DMView } from "./components/dm/DMView"; import { NotificationsView } from "./components/notifications/NotificationsView"; import { BookmarkView } from "./components/bookmark/BookmarkView"; import { HashtagFeed } from "./components/feed/HashtagFeed"; +import { PodcastsView } from "./components/podcast/PodcastsView"; +import { PodcastPlayerBar } from "./components/podcast/PodcastPlayerBar"; import { HelpModal } from "./components/shared/HelpModal"; import { useUIStore } from "./stores/ui"; import { useUpdater } from "./hooks/useUpdater"; @@ -102,8 +104,10 @@ function App() { {currentView === "notifications" && } {currentView === "bookmarks" && } {currentView === "hashtag" && } + {currentView === "podcasts" && } + {showHelp && } ); diff --git a/src/components/feed/FountainCard.tsx b/src/components/feed/FountainCard.tsx new file mode 100644 index 0000000..e470161 --- /dev/null +++ b/src/components/feed/FountainCard.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect } from "react"; +import type { ContentSegment } from "../../lib/parsing"; +import type { PodcastEpisode } from "../../types/podcast"; +import { resolveFountainEpisode } from "../../lib/podcast"; +import { usePodcastStore } from "../../stores/podcast"; + +export function FountainCard({ seg }: { seg: ContentSegment }) { + const [episode, setEpisode] = useState(null); + const [loading, setLoading] = useState(true); + const [failed, setFailed] = useState(false); + const play = usePodcastStore((s) => s.play); + + useEffect(() => { + resolveFountainEpisode(seg.value).then((ep) => { + if (ep) setEpisode(ep); + else setFailed(true); + setLoading(false); + }); + }, [seg.value]); + + if (failed) { + // Fallback: render as a regular link + return ( + +
+ F +
+
+
Fountain.fm
+
{seg.value}
+
+
+ ); + } + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (!episode) return null; + + const handlePlay = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (episode.enclosureUrl) { + play(episode); + } + }; + + return ( +
+ {episode.artworkUrl ? ( + { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : ( +
+ F +
+ )} +
+
Fountain.fm
+
{episode.title}
+ {episode.showTitle && ( +
{episode.showTitle}
+ )} +
+ {episode.enclosureUrl && ( + + )} +
+ ); +} diff --git a/src/components/feed/MediaCards.tsx b/src/components/feed/MediaCards.tsx index 32f9a51..7ab8769 100644 --- a/src/components/feed/MediaCards.tsx +++ b/src/components/feed/MediaCards.tsx @@ -1,4 +1,5 @@ import { ContentSegment } from "../../lib/parsing"; +import { usePodcastStore } from "../../stores/podcast"; export function VideoBlock({ sources }: { sources: string[] }) { if (sources.length === 0) return null; @@ -20,6 +21,7 @@ export function VideoBlock({ sources }: { sources: string[] }) { } export function AudioBlock({ sources }: { sources: string[] }) { + const play = usePodcastStore((s) => s.play); if (sources.length === 0) return null; return (
@@ -27,7 +29,27 @@ export function AudioBlock({ sources }: { sources: string[] }) { const filename = src.split("/").pop()?.split("?")[0] ?? src; return (
-
{filename}
+
+
{filename}
+ +
); diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx index cd58c4c..dd5e776 100644 --- a/src/components/feed/NoteContent.tsx +++ b/src/components/feed/NoteContent.tsx @@ -8,6 +8,7 @@ import { ImageLightbox } from "../shared/ImageLightbox"; import { parseContent } from "../../lib/parsing"; import { renderTextSegments } from "./TextSegments"; import { VideoBlock, AudioBlock, YouTubeCard, VimeoCard, SpotifyCard, TidalCard } from "./MediaCards"; +import { FountainCard } from "./FountainCard"; function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (index: number) => void }) { const count = images.length; @@ -159,6 +160,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { const vimeos = segments.filter((s) => s.type === "vimeo"); const spotifys = segments.filter((s) => s.type === "spotify"); const tidals = segments.filter((s) => s.type === "tidal"); + const fountains = segments.filter((s) => s.type === "fountain"); const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value); const [lightboxIndex, setLightboxIndex] = useState(null); @@ -185,7 +187,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { // --- Media blocks only (rendered OUTSIDE the clickable wrapper) --- if (mediaOnly) { const hasMedia = videos.length > 0 || audios.length > 0 || youtubes.length > 0 - || vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || quoteIds.length > 0; + || vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || fountains.length > 0 || quoteIds.length > 0; if (!hasMedia) return null; return ( @@ -196,6 +198,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { {vimeos.map((seg, i) => )} {spotifys.map((seg, i) => )} {tidals.map((seg, i) => )} + {fountains.map((seg, i) => )} {quoteIds.map((id) => )}
); @@ -225,6 +228,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { {vimeos.map((seg, i) => )} {spotifys.map((seg, i) => )} {tidals.map((seg, i) => )} + {fountains.map((seg, i) => )} {quoteIds.map((id) => )}
); diff --git a/src/components/notifications/NotificationsView.tsx b/src/components/notifications/NotificationsView.tsx index dbb9abf..bc832d6 100644 --- a/src/components/notifications/NotificationsView.tsx +++ b/src/components/notifications/NotificationsView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; import { useNotificationsStore } from "../../stores/notifications"; @@ -10,24 +10,20 @@ export function NotificationsView() { const { notifications, unreadCount, - lastSeenAt, loading, fetchNotifications, markAllRead, + markRead, + isRead, } = useNotificationsStore(); const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore(); const filteredNotifications = notifications.filter( - (e) => !mutedPubkeys.includes(e.pubkey) && !contentMatchesMutedKeyword(e.content) + (e) => e.pubkey !== pubkey && !mutedPubkeys.includes(e.pubkey) && !contentMatchesMutedKeyword(e.content) ); - // Capture lastSeenAt at mount time so unread highlights persist during this view session - const prevLastSeenAtRef = useRef(lastSeenAt); - useEffect(() => { if (!pubkey) return; - fetchNotifications(pubkey).then(() => { - setTimeout(() => markAllRead(), 500); - }); + fetchNotifications(pubkey); }, [pubkey]); if (!loggedIn || !pubkey) { @@ -65,11 +61,12 @@ export function NotificationsView() { )} {filteredNotifications.map((event) => { - const isUnread = (event.created_at ?? 0) > prevLastSeenAtRef.current; + const read = isRead(event.id!); return (
{ if (!read && event.id) markRead(event.id); }} >
diff --git a/src/components/podcast/EpisodeList.tsx b/src/components/podcast/EpisodeList.tsx new file mode 100644 index 0000000..483a559 --- /dev/null +++ b/src/components/podcast/EpisodeList.tsx @@ -0,0 +1,124 @@ +import { useState, useEffect } from "react"; +import type { PodcastShow, PodcastEpisode } from "../../types/podcast"; +import { getEpisodes } from "../../lib/podcast"; +import { usePodcastStore } from "../../stores/podcast"; + +function formatDuration(seconds: number): string { + if (!seconds) return ""; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function formatDate(ts: number): string { + if (!ts) return ""; + return new Date(ts * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" }); +} + +interface EpisodeListProps { + show: PodcastShow; + onBack: () => void; +} + +export function EpisodeList({ show, onBack }: EpisodeListProps) { + const [episodes, setEpisodes] = useState([]); + const [loading, setLoading] = useState(true); + const play = usePodcastStore((s) => s.play); + const currentGuid = usePodcastStore((s) => s.currentEpisode?.guid); + const progressMap = usePodcastStore((s) => s.progressMap); + + useEffect(() => { + if (!show.podcastIndexId) return; + setLoading(true); + getEpisodes(show.podcastIndexId).then((eps) => { + // Enrich episodes with show info + setEpisodes(eps.map((ep) => ({ + ...ep, + showTitle: show.title, + showArtworkUrl: show.artworkUrl, + }))); + setLoading(false); + }); + }, [show.podcastIndexId]); + + return ( +
+ {/* Header */} +
+ + {show.artworkUrl && ( + { (e.target as HTMLImageElement).style.display = "none"; }} + /> + )} +
+

{show.title}

+
{show.author}
+ {show.description && ( +
+ {show.description.replace(/<[^>]+>/g, "").slice(0, 200)} +
+ )} +
+
+ + {/* Episodes */} +
+ {loading ? ( +
Loading episodes...
+ ) : episodes.length === 0 ? ( +
No episodes found
+ ) : ( + episodes.map((ep) => { + const isPlaying = currentGuid === ep.guid; + const progress = progressMap[ep.guid]; + const hasProgress = progress && progress.position > 10; + return ( + + ); + }) + )} +
+
+ ); +} diff --git a/src/components/podcast/PodcastCard.tsx b/src/components/podcast/PodcastCard.tsx new file mode 100644 index 0000000..f8a7e8d --- /dev/null +++ b/src/components/podcast/PodcastCard.tsx @@ -0,0 +1,38 @@ +import type { PodcastShow } from "../../types/podcast"; + +interface PodcastCardProps { + show: PodcastShow; + onClick: () => void; +} + +export function PodcastCard({ show, onClick }: PodcastCardProps) { + return ( + + ); +} diff --git a/src/components/podcast/PodcastPlayerBar.tsx b/src/components/podcast/PodcastPlayerBar.tsx new file mode 100644 index 0000000..a99620b --- /dev/null +++ b/src/components/podcast/PodcastPlayerBar.tsx @@ -0,0 +1,336 @@ +import { useRef, useEffect, useCallback, useState } from "react"; +import type { PodcastEpisode } from "../../types/podcast"; +import { usePodcastStore } from "../../stores/podcast"; +import { publishNote } from "../../lib/nostr"; +import { V4VIndicator } from "./V4VIndicator"; + +function formatTime(seconds: number): string { + if (!seconds || !isFinite(seconds)) return "0:00"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; +} + +function ShareButton({ episode }: { episode: PodcastEpisode | null }) { + const [state, setState] = useState<"idle" | "confirm" | "shared">("idle"); + + const handleClick = useCallback(() => { + if (!episode) return; + if (state === "idle") { + setState("confirm"); + } else if (state === "confirm") { + const text = `Listening to ${episode.title} from ${episode.showTitle}\n\n${episode.enclosureUrl}`; + publishNote(text).then(() => { + setState("shared"); + setTimeout(() => setState("idle"), 3000); + }).catch(() => setState("idle")); + } + }, [episode, state]); + + const handleCancel = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setState("idle"); + }, []); + + return ( + + + {state === "confirm" && ( + + )} + + ); +} + +const RATES = [1, 1.5, 2]; + +export function PodcastPlayerBar() { + const audioRef = useRef(null); + const saveTimerRef = useRef(null); + const [audioError, setAudioError] = useState(null); + + const episode = usePodcastStore((s) => s.currentEpisode); + const playbackState = usePodcastStore((s) => s.playbackState); + const currentTime = usePodcastStore((s) => s.currentTime); + const duration = usePodcastStore((s) => s.duration); + const playbackRate = usePodcastStore((s) => s.playbackRate); + const volume = usePodcastStore((s) => s.volume); + const playCounter = usePodcastStore((s) => s.playCounter); + + const { + pause, resume, seek, setRate, setVolume, + setPlaybackState, setCurrentTime, setDuration, + saveProgress, stop, + } = usePodcastStore.getState(); + + // Drive audio element when episode changes + useEffect(() => { + const audio = audioRef.current; + if (!audio || !episode) return; + + setAudioError(null); + setPlaybackState("loading"); + + // Set source and let it load + audio.src = episode.enclosureUrl; + audio.playbackRate = playbackRate; + audio.volume = volume; + + // Wait for the audio to be ready, then seek + play + const onCanPlay = () => { + audio.removeEventListener("canplaythrough", onCanPlay); + const savedPosition = usePodcastStore.getState().loadProgress(episode.guid); + if (savedPosition > 0) { + try { audio.currentTime = savedPosition; } catch { /* ignore */ } + } + audio.play().catch(() => setPlaybackState("paused")); + }; + audio.addEventListener("canplaythrough", onCanPlay); + + return () => audio.removeEventListener("canplaythrough", onCanPlay); + }, [episode, playCounter]); + + // Sync playback rate + useEffect(() => { + if (audioRef.current) audioRef.current.playbackRate = playbackRate; + }, [playbackRate]); + + // Sync volume + useEffect(() => { + if (audioRef.current) audioRef.current.volume = volume; + }, [volume]); + + // Handle play/pause state changes + useEffect(() => { + const audio = audioRef.current; + if (!audio || !episode) return; + + if (playbackState === "playing" && audio.paused) { + audio.play().catch(() => {}); + } else if (playbackState === "paused" && !audio.paused) { + audio.pause(); + } + }, [playbackState]); + + // Auto-save progress every 15s + useEffect(() => { + if (!episode) return; + saveTimerRef.current = window.setInterval(() => { + saveProgress(); + }, 15000); + return () => { + if (saveTimerRef.current) clearInterval(saveTimerRef.current); + }; + }, [episode, playCounter]); + + // Space key to toggle play/pause — use audio element state, not store state + useEffect(() => { + if (!episode) return; + const handler = (e: KeyboardEvent) => { + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return; + if (e.code === "Space") { + e.preventDefault(); + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + audio.play().catch(() => {}); + } else { + audio.pause(); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [episode]); + + const handleTimeUpdate = useCallback(() => { + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + } + }, []); + + const handleLoadedMetadata = useCallback(() => { + if (audioRef.current) { + setDuration(audioRef.current.duration); + } + }, []); + + const saveAndStop = useCallback(() => { + // Sync current time from audio element before saving (store value may be stale) + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + audioRef.current.pause(); + audioRef.current.removeAttribute("src"); + } + saveProgress(); + stop(); + }, []); + + const handleEnded = useCallback(() => { + if (audioRef.current) { + audioRef.current.removeAttribute("src"); + } + stop(); + }, []); + + const handleSeek = useCallback((e: React.ChangeEvent) => { + const t = parseFloat(e.target.value); + seek(t); + if (audioRef.current) { + audioRef.current.currentTime = t; + // Always resume after seeking — clicking the slider can trigger a pause event + // before onChange fires, so checking state here is unreliable + setPlaybackState("loading"); + audioRef.current.play().catch(() => {}); + } + }, []); + + const cycleRate = useCallback(() => { + const idx = RATES.indexOf(playbackRate); + setRate(RATES[(idx + 1) % RATES.length]); + }, [playbackRate]); + + const artwork = episode?.artworkUrl || episode?.showArtworkUrl; + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + return ( + <> +