Add podcast playback, Fountain.fm cards, V4V streaming, fix notifications

Podcast feature:
- Podcast discovery via Podcast Index API (trending + search)
- Persistent player bar with play/pause, seek, speed (1x/1.5x/2x), volume
- Audio persists across view navigation, resumes from saved position
- Fountain.fm URL detection in feed with rich playable cards
- "Play in Wrystr" button on inline audio blocks
- V4V streaming sats via NWC (LNURL-pay, 5min accumulation, split payments)
- Share what you're listening to (publish note with confirm)
- Space key toggles play/pause globally

Notification fixes:
- Per-notification read tracking (click to mark read) instead of mark-all-on-open
- Read notifications persist at 50% opacity, unread get accent border
- Always fetches last 7 days, keeps 15 most recent
- Filter out own replies from notifications
- Sidebar badge shows only unread count
This commit is contained in:
Jure
2026-03-21 12:53:05 +01:00
parent 1dafb3b456
commit 04180cf186
20 changed files with 1474 additions and 29 deletions

View File

@@ -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<PodcastEpisode | null>(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 (
<a
href={seg.value}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
>
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center shrink-0">
<span className="text-blue-400 text-lg font-bold">F</span>
</div>
<div className="min-w-0">
<div className="text-[11px] text-text-muted">Fountain.fm</div>
<div className="text-[12px] text-accent truncate">{seg.value}</div>
</div>
</a>
);
}
if (loading) {
return (
<div className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 animate-pulse">
<div className="w-10 h-10 rounded-sm bg-bg shrink-0" />
<div className="min-w-0 flex-1">
<div className="h-3 bg-bg rounded w-32 mb-1" />
<div className="h-2 bg-bg rounded w-20" />
</div>
</div>
);
}
if (!episode) return null;
const handlePlay = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (episode.enclosureUrl) {
play(episode);
}
};
return (
<div
className="mt-2 flex items-center gap-3 rounded-sm bg-bg-raised border border-border p-3 hover:bg-bg-hover transition-colors cursor-pointer"
onClick={handlePlay}
>
{episode.artworkUrl ? (
<img
src={episode.artworkUrl}
alt=""
className="w-12 h-12 rounded-sm object-cover shrink-0"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-12 h-12 rounded-sm bg-blue-500/20 flex items-center justify-center shrink-0">
<span className="text-blue-400 text-lg font-bold">F</span>
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-[11px] text-text-muted">Fountain.fm</div>
<div className="text-[12px] text-text truncate">{episode.title}</div>
{episode.showTitle && (
<div className="text-[10px] text-text-dim truncate">{episode.showTitle}</div>
)}
</div>
{episode.enclosureUrl && (
<button
onClick={handlePlay}
className="shrink-0 w-8 h-8 rounded-full border border-border flex items-center justify-center hover:bg-accent/10 transition-colors"
title="Play in Wrystr"
>
<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor" className="text-accent ml-0.5">
<polygon points="0,0 10,6 0,12" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -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 (
<div className="mt-2 flex flex-col gap-2">
@@ -27,7 +29,27 @@ export function AudioBlock({ sources }: { sources: string[] }) {
const filename = src.split("/").pop()?.split("?")[0] ?? src;
return (
<div key={i} className="rounded-sm bg-bg-raised border border-border p-2">
<div className="text-[11px] text-text-muted mb-1 truncate">{filename}</div>
<div className="flex items-center justify-between mb-1">
<div className="text-[11px] text-text-muted truncate">{filename}</div>
<button
onClick={(e) => {
e.stopPropagation();
play({
guid: `audio:${src}`,
title: filename,
enclosureUrl: src,
pubDate: 0,
duration: 0,
description: "",
showTitle: "From note",
showArtworkUrl: "",
});
}}
className="text-[10px] text-accent hover:text-accent-hover transition-colors shrink-0 ml-2"
>
play in wrystr
</button>
</div>
<audio controls preload="metadata" className="w-full h-8" src={src} />
</div>
);

View File

@@ -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<number | null>(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) => <VimeoCard key={`vim-${i}`} seg={seg} />)}
{spotifys.map((seg, i) => <SpotifyCard key={`sp-${i}`} seg={seg} />)}
{tidals.map((seg, i) => <TidalCard key={`td-${i}`} seg={seg} />)}
{fountains.map((seg, i) => <FountainCard key={`fn-${i}`} seg={seg} />)}
{quoteIds.map((id) => <QuotePreview key={id} eventId={id} />)}
</div>
);
@@ -225,6 +228,7 @@ export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) {
{vimeos.map((seg, i) => <VimeoCard key={`vim-${i}`} seg={seg} />)}
{spotifys.map((seg, i) => <SpotifyCard key={`sp-${i}`} seg={seg} />)}
{tidals.map((seg, i) => <TidalCard key={`td-${i}`} seg={seg} />)}
{fountains.map((seg, i) => <FountainCard key={`fn-${i}`} seg={seg} />)}
{quoteIds.map((id) => <QuotePreview key={id} eventId={id} />)}
</div>
);

View File

@@ -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 (
<div
key={event.id}
className={isUnread ? "border-l-2 border-accent/40" : ""}
className={`transition-opacity ${read ? "opacity-50" : "border-l-2 border-accent/40"}`}
onClick={() => { if (!read && event.id) markRead(event.id); }}
>
<NoteCard event={event} />
</div>

View File

@@ -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<PodcastEpisode[]>([]);
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 (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b border-border p-4 flex items-start gap-4 shrink-0">
<button
onClick={onBack}
className="text-text-muted hover:text-accent text-[12px] mt-1 shrink-0"
>
back
</button>
{show.artworkUrl && (
<img
src={show.artworkUrl}
alt=""
className="w-20 h-20 rounded-sm object-cover shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<div className="min-w-0">
<h2 className="text-[15px] text-text font-semibold">{show.title}</h2>
<div className="text-[12px] text-text-muted">{show.author}</div>
{show.description && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-3">
{show.description.replace(/<[^>]+>/g, "").slice(0, 200)}
</div>
)}
</div>
</div>
{/* Episodes */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-4 text-text-dim text-[12px]">Loading episodes...</div>
) : episodes.length === 0 ? (
<div className="p-4 text-text-dim text-[12px]">No episodes found</div>
) : (
episodes.map((ep) => {
const isPlaying = currentGuid === ep.guid;
const progress = progressMap[ep.guid];
const hasProgress = progress && progress.position > 10;
return (
<button
key={ep.guid}
onClick={() => play(ep)}
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-bg-hover transition-colors flex items-center gap-3 ${
isPlaying ? "bg-accent/5" : ""
}`}
>
<div className="w-6 shrink-0 flex items-center justify-center">
{isPlaying ? (
<span className="text-accent text-[12px]">*</span>
) : (
<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor" className="text-text-dim">
<polygon points="0,0 10,6 0,12" />
</svg>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-[12px] text-text truncate">{ep.title}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-text-dim">{formatDate(ep.pubDate)}</span>
{ep.duration > 0 && (
<span className="text-[10px] text-text-dim">{formatDuration(ep.duration)}</span>
)}
{hasProgress && (
<span className="text-[10px] text-accent">resumed</span>
)}
{ep.value && ep.value.length > 0 && (
<span className="text-[10px] text-amber-500">V4V</span>
)}
</div>
</div>
</button>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { PodcastShow } from "../../types/podcast";
interface PodcastCardProps {
show: PodcastShow;
onClick: () => void;
}
export function PodcastCard({ show, onClick }: PodcastCardProps) {
return (
<button
onClick={onClick}
className="flex items-start gap-3 p-3 rounded-sm bg-bg-raised border border-border hover:bg-bg-hover transition-colors text-left w-full"
>
{show.artworkUrl ? (
<img
src={show.artworkUrl}
alt=""
className="w-16 h-16 rounded-sm object-cover shrink-0 bg-bg"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<div className="w-16 h-16 rounded-sm bg-bg flex items-center justify-center shrink-0 text-2xl text-text-dim">
P
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-[13px] text-text font-medium truncate">{show.title}</div>
<div className="text-[11px] text-text-muted truncate">{show.author}</div>
{show.description && (
<div className="text-[11px] text-text-dim mt-1 line-clamp-2">
{show.description.replace(/<[^>]+>/g, "").slice(0, 120)}
</div>
)}
</div>
</button>
);
}

View File

@@ -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 (
<span className="shrink-0 flex items-center gap-1">
<button
onClick={handleClick}
className={`text-[11px] transition-colors ${
state === "shared" ? "text-success"
: state === "confirm" ? "text-accent"
: "text-text-dim hover:text-text"
}`}
title="Share what you're listening to"
>
{state === "shared" ? "shared" : state === "confirm" ? "publish?" : "share"}
</button>
{state === "confirm" && (
<button onClick={handleCancel} className="text-[10px] text-text-dim hover:text-text">x</button>
)}
</span>
);
}
const RATES = [1, 1.5, 2];
export function PodcastPlayerBar() {
const audioRef = useRef<HTMLAudioElement>(null);
const saveTimerRef = useRef<number | null>(null);
const [audioError, setAudioError] = useState<string | null>(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<HTMLInputElement>) => {
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 (
<>
<audio
ref={audioRef}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onPlay={() => setPlaybackState("playing")}
onPause={() => {
const s = usePodcastStore.getState().playbackState;
// Only mark paused if we were actually playing — ignore pause events from src changes
if (s === "playing") setPlaybackState("paused");
}}
onWaiting={() => {
const s = usePodcastStore.getState().playbackState;
if (s === "playing") setPlaybackState("loading");
}}
onError={() => {
const code = audioRef.current?.error?.code;
const msg = audioRef.current?.error?.message;
setAudioError(`Audio error ${code}: ${msg || "failed to load"}`);
setPlaybackState("paused");
}}
/>
{!episode ? null : (
<div className="shrink-0 h-14 border-t border-border bg-bg flex items-center gap-3 px-3">
{/* Artwork */}
{artwork && (
<img
src={artwork}
alt=""
className="w-10 h-10 rounded-sm object-cover shrink-0 bg-bg-raised"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
{/* Title + show */}
<div className="min-w-0 w-40 shrink-0">
{audioError && <div className="text-[10px] text-danger truncate">{audioError}</div>}
<div className="text-[12px] text-text truncate">{episode.title}</div>
<div className="text-[10px] text-text-dim truncate">{episode.showTitle}</div>
</div>
{/* Play/Pause */}
<button
onClick={() => playbackState === "playing" ? pause() : resume()}
className="w-8 h-8 rounded-full border border-border flex items-center justify-center hover:bg-bg-hover transition-colors shrink-0"
title={playbackState === "playing" ? "Pause" : "Play"}
>
{playbackState === "playing" ? (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" className="text-text">
<rect x="2" y="1" width="3" height="10" rx="0.5" />
<rect x="7" y="1" width="3" height="10" rx="0.5" />
</svg>
) : playbackState === "loading" ? (
<span className="text-[10px] text-text-dim animate-pulse">...</span>
) : (
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" className="text-text ml-0.5">
<polygon points="2,1 11,6 2,11" />
</svg>
)}
</button>
{/* Seek bar */}
<div className="flex-1 flex items-center gap-2 min-w-0">
<span className="text-[10px] text-text-dim w-12 text-right shrink-0">{formatTime(currentTime)}</span>
<div className="flex-1 relative h-4 flex items-center">
<div className="absolute inset-x-0 h-1 bg-border rounded-full">
<div
className="h-full bg-accent rounded-full transition-[width] duration-200"
style={{ width: `${progress}%` }}
/>
</div>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={handleSeek}
className="absolute inset-0 w-full opacity-0 cursor-pointer"
/>
</div>
<span className="text-[10px] text-text-dim w-12 shrink-0">{formatTime(duration)}</span>
</div>
{/* Speed */}
<button
onClick={cycleRate}
className="text-[11px] text-text-muted hover:text-accent transition-colors px-1 shrink-0"
title="Playback speed"
>
{playbackRate}x
</button>
{/* Volume */}
<div className="flex items-center gap-1 shrink-0">
<span className="text-[11px] text-text-dim">vol</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-16 accent-accent h-1 cursor-pointer"
/>
</div>
{/* V4V */}
<V4VIndicator />
{/* Share */}
<ShareButton episode={episode} />
{/* Close */}
<button
onClick={saveAndStop}
className="text-text-dim hover:text-text transition-colors shrink-0 text-[14px]"
title="Stop"
>
x
</button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,115 @@
import { useState, useEffect, useCallback } from "react";
import type { PodcastShow } from "../../types/podcast";
import { searchPodcasts, getTrending } from "../../lib/podcast";
import { PodcastCard } from "./PodcastCard";
import { EpisodeList } from "./EpisodeList";
type Tab = "trending" | "search";
export function PodcastsView() {
const [tab, setTab] = useState<Tab>("trending");
const [query, setQuery] = useState("");
const [shows, setShows] = useState<PodcastShow[]>([]);
const [loading, setLoading] = useState(false);
const [selectedShow, setSelectedShow] = useState<PodcastShow | null>(null);
// Load trending on mount
useEffect(() => {
setLoading(true);
getTrending().then((results) => {
setShows(results);
setLoading(false);
});
}, []);
const handleSearch = useCallback(async () => {
if (!query.trim()) return;
setTab("search");
setLoading(true);
const results = await searchPodcasts(query.trim());
setShows(results);
setLoading(false);
}, [query]);
const handleTabChange = useCallback(async (t: Tab) => {
setTab(t);
setSelectedShow(null);
if (t === "trending") {
setLoading(true);
const results = await getTrending();
setShows(results);
setLoading(false);
}
}, []);
// Show episode list if a show is selected
if (selectedShow) {
return <EpisodeList show={selectedShow} onBack={() => setSelectedShow(null)} />;
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b border-border px-4 py-3 shrink-0">
<h1 className="text-[14px] text-text font-semibold mb-3">Podcasts</h1>
{/* Search */}
<div className="flex gap-2 mb-3">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search podcasts..."
className="flex-1 bg-bg-raised border border-border rounded-sm px-3 py-1.5 text-[12px] text-text placeholder:text-text-dim focus:outline-none focus:border-accent"
/>
<button
onClick={handleSearch}
disabled={!query.trim()}
className="px-3 py-1.5 bg-accent/10 text-accent text-[12px] rounded-sm hover:bg-accent/20 transition-colors disabled:opacity-40"
>
search
</button>
</div>
{/* Tabs */}
<div className="flex gap-4">
{(["trending", "search"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => handleTabChange(t)}
className={`text-[12px] pb-1 border-b-2 transition-colors ${
tab === t
? "text-accent border-accent"
: "text-text-muted border-transparent hover:text-text"
}`}
>
{t === "trending" ? "Trending" : "Search Results"}
</button>
))}
</div>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="text-text-dim text-[12px]">Loading...</div>
) : shows.length === 0 ? (
<div className="text-text-dim text-[12px]">
{tab === "search" ? "No results. Try a different search." : "No trending podcasts found."}
</div>
) : (
<div className="grid grid-cols-1 gap-2">
{shows.map((show, i) => (
<PodcastCard
key={show.podcastIndexId ?? i}
show={show}
onClick={() => setSelectedShow(show)}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
import { useState, useCallback } from "react";
import { usePodcastStore } from "../../stores/podcast";
import { startStreaming, stopStreaming, boost } from "../../lib/podcast/v4v";
const RATE_OPTIONS = [5, 10, 21, 50, 100];
const NWC_KEY = "wrystr_nwc_uri";
export function V4VIndicator() {
const [open, setOpen] = useState(false);
const [boostAmount, setBoostAmount] = useState("100");
const [boosting, setBoosting] = useState(false);
const episode = usePodcastStore((s) => s.currentEpisode);
const v4vSatsPerMinute = usePodcastStore((s) => s.v4vSatsPerMinute);
const v4vStreaming = usePodcastStore((s) => s.v4vStreaming);
const v4vTotalStreamed = usePodcastStore((s) => s.v4vTotalStreamed);
const { setV4VEnabled, setV4VSatsPerMinute, setV4VStreaming, addStreamedSats } = usePodcastStore.getState();
const nwcUri = localStorage.getItem(NWC_KEY) ?? "";
const hasWallet = !!nwcUri;
const hasRecipients = episode?.value && episode.value.length > 0;
const toggleStreaming = useCallback(() => {
if (!episode || !hasWallet) return;
if (v4vStreaming) {
stopStreaming();
setV4VStreaming(false);
setV4VEnabled(false);
} else {
const intervalId = startStreaming(
episode,
v4vSatsPerMinute,
nwcUri,
(amount) => addStreamedSats(amount),
);
if (intervalId >= 0) {
setV4VStreaming(true, intervalId);
setV4VEnabled(true);
}
}
}, [episode, v4vStreaming, v4vSatsPerMinute, nwcUri, hasWallet]);
const handleBoost = useCallback(async () => {
if (!episode || !hasWallet || boosting) return;
const amount = parseInt(boostAmount);
if (!amount || amount <= 0) return;
setBoosting(true);
const paid = await boost(episode, amount, nwcUri);
if (paid > 0) addStreamedSats(paid);
setBoosting(false);
}, [episode, boostAmount, nwcUri, hasWallet, boosting]);
if (!episode) return null;
return (
<div className="relative shrink-0">
<button
onClick={() => setOpen(!open)}
className={`text-[11px] px-1.5 py-0.5 rounded-sm transition-colors ${
v4vStreaming
? "text-amber-400 bg-amber-500/10 animate-pulse"
: "text-text-dim hover:text-text"
}`}
title="Value 4 Value"
>
{v4vStreaming ? `${v4vTotalStreamed} sats` : "V4V"}
</button>
{open && (
<div className="absolute bottom-full right-0 mb-2 w-56 bg-bg border border-border rounded-sm shadow-lg p-3 z-50">
<div className="text-[11px] text-text font-medium mb-2">Value 4 Value</div>
{!hasWallet && (
<div className="text-[10px] text-text-dim mb-2">
Connect NWC wallet in Settings to stream sats.
</div>
)}
{!hasRecipients && hasWallet && (
<div className="text-[10px] text-text-dim mb-2">
This episode has no V4V recipients.
</div>
)}
{hasWallet && hasRecipients && (
<>
{/* Toggle */}
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] text-text-muted">Stream sats</span>
<button
onClick={toggleStreaming}
className={`w-8 h-4 rounded-full transition-colors relative ${
v4vStreaming ? "bg-accent" : "bg-border"
}`}
>
<span
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white transition-transform ${
v4vStreaming ? "left-4" : "left-0.5"
}`}
/>
</button>
</div>
{/* Rate picker */}
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] text-text-dim shrink-0">Rate:</span>
{RATE_OPTIONS.map((rate) => (
<button
key={rate}
onClick={() => setV4VSatsPerMinute(rate)}
className={`text-[10px] px-1.5 py-0.5 rounded-sm transition-colors ${
v4vSatsPerMinute === rate
? "bg-accent/20 text-accent"
: "text-text-dim hover:text-text"
}`}
>
{rate}
</button>
))}
<span className="text-[9px] text-text-dim">/min</span>
</div>
{/* Boost */}
<div className="flex items-center gap-1 border-t border-border pt-2">
<input
type="number"
value={boostAmount}
onChange={(e) => setBoostAmount(e.target.value)}
className="w-16 bg-bg-raised border border-border rounded-sm px-1.5 py-0.5 text-[10px] text-text"
min="1"
/>
<button
onClick={handleBoost}
disabled={boosting}
className="text-[10px] text-accent hover:text-accent-hover px-2 py-0.5 bg-accent/10 rounded-sm disabled:opacity-40"
>
{boosting ? "..." : "boost"}
</button>
</div>
{v4vTotalStreamed > 0 && (
<div className="text-[9px] text-text-dim mt-2">
Total streamed: {v4vTotalStreamed} sats
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View File

@@ -12,6 +12,7 @@ const NAV_ITEMS = [
{ id: "feed" as const, label: "feed", icon: "◈" },
{ id: "articles" as const, label: "articles", icon: "☰" },
{ id: "media" as const, label: "media", icon: "▶" },
{ id: "podcasts" as const, label: "podcasts", icon: "P" },
{ id: "search" as const, label: "search", icon: "⌕" },
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
{ id: "dm" as const, label: "messages", icon: "✉" },