mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 20:29:12 -07:00
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:
101
src/components/feed/FountainCard.tsx
Normal file
101
src/components/feed/FountainCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
124
src/components/podcast/EpisodeList.tsx
Normal file
124
src/components/podcast/EpisodeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/podcast/PodcastCard.tsx
Normal file
38
src/components/podcast/PodcastCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
src/components/podcast/PodcastPlayerBar.tsx
Normal file
336
src/components/podcast/PodcastPlayerBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
115
src/components/podcast/PodcastsView.tsx
Normal file
115
src/components/podcast/PodcastsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/components/podcast/V4VIndicator.tsx
Normal file
154
src/components/podcast/V4VIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: "✉" },
|
||||
|
||||
Reference in New Issue
Block a user