mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 04:39: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:
@@ -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" && <NotificationsView />}
|
||||
{currentView === "bookmarks" && <BookmarkView />}
|
||||
{currentView === "hashtag" && <HashtagFeed />}
|
||||
{currentView === "podcasts" && <PodcastsView />}
|
||||
</main>
|
||||
</div>
|
||||
<PodcastPlayerBar />
|
||||
{showHelp && <HelpModal onClose={toggleHelp} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
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: "✉" },
|
||||
|
||||
@@ -9,11 +9,12 @@ const YOUTUBE_REGEX = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be
|
||||
const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z0-9-]+)/;
|
||||
const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/;
|
||||
const VIMEO_REGEX = /vimeo\.com\/(\d+)/;
|
||||
const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/;
|
||||
const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g;
|
||||
const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g;
|
||||
|
||||
export interface ContentSegment {
|
||||
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "mention" | "hashtag" | "quote";
|
||||
type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "fountain" | "mention" | "hashtag" | "quote";
|
||||
value: string; // for "quote": the hex event ID
|
||||
display?: string;
|
||||
mediaId?: string; // video/embed ID for youtube/vimeo
|
||||
@@ -84,6 +85,13 @@ export function parseContent(content: string): ContentSegment[] {
|
||||
length: cleaned.length,
|
||||
segment: { type: "tidal", value: cleaned, mediaType: tidalTypeMatch?.[1] ?? "track", mediaId: tidalMatch[1] },
|
||||
});
|
||||
} else if (FOUNTAIN_REGEX.test(cleaned)) {
|
||||
const fmMatch = cleaned.match(FOUNTAIN_REGEX);
|
||||
allMatches.push({
|
||||
index: match.index,
|
||||
length: cleaned.length,
|
||||
segment: { type: "fountain", value: cleaned, mediaType: fmMatch?.[1] ?? "episode", mediaId: fmMatch?.[2] },
|
||||
});
|
||||
} else {
|
||||
// Shorten display URL
|
||||
let display = cleaned;
|
||||
|
||||
67
src/lib/podcast/fountainFm.ts
Normal file
67
src/lib/podcast/fountainFm.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { PodcastEpisode } from "../../types/podcast";
|
||||
|
||||
export const FOUNTAIN_REGEX = /fountain\.fm\/(episode|show)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
const CACHE_KEY = "wrystr_fountain_cache";
|
||||
|
||||
function loadCache(): Record<string, PodcastEpisode> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(CACHE_KEY) ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCache(cache: Record<string, PodcastEpisode>) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export async function resolveFountainEpisode(url: string): Promise<PodcastEpisode | null> {
|
||||
const cache = loadCache();
|
||||
if (cache[url]) return cache[url];
|
||||
|
||||
try {
|
||||
// Fetch the Fountain.fm page and extract og: meta tags
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return null;
|
||||
const html = await res.text();
|
||||
|
||||
const getMetaContent = (property: string): string => {
|
||||
const regex = new RegExp(`<meta[^>]+property=["']${property}["'][^>]+content=["']([^"']+)["']`, "i");
|
||||
const altRegex = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${property}["']`, "i");
|
||||
const match = html.match(regex) || html.match(altRegex);
|
||||
return match?.[1] ?? "";
|
||||
};
|
||||
|
||||
const title = getMetaContent("og:title");
|
||||
const description = getMetaContent("og:description");
|
||||
const artwork = getMetaContent("og:image");
|
||||
|
||||
// Look for an audio URL in the page (meta or direct link)
|
||||
const audioMatch = html.match(/<meta[^>]+content=["'](https?:\/\/[^"']+\.(mp3|m4a|ogg|opus)[^"']*?)["']/i)
|
||||
|| html.match(/["'](https?:\/\/[^"'\s]+\.(mp3|m4a|ogg|opus)[^"'\s]*?)["']/i);
|
||||
const enclosureUrl = audioMatch?.[1] ?? "";
|
||||
|
||||
if (!title) return null;
|
||||
|
||||
const episode: PodcastEpisode = {
|
||||
guid: `fountain:${url}`,
|
||||
title,
|
||||
enclosureUrl,
|
||||
pubDate: 0,
|
||||
duration: 0,
|
||||
description,
|
||||
artworkUrl: artwork || undefined,
|
||||
showTitle: "",
|
||||
showArtworkUrl: artwork,
|
||||
};
|
||||
|
||||
cache[url] = episode;
|
||||
saveCache(cache);
|
||||
return episode;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
2
src/lib/podcast/index.ts
Normal file
2
src/lib/podcast/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { searchPodcasts, getEpisodes, getTrending, getPodcastByFeedUrl } from "./podcastIndex";
|
||||
export { resolveFountainEpisode, FOUNTAIN_REGEX } from "./fountainFm";
|
||||
101
src/lib/podcast/podcastIndex.ts
Normal file
101
src/lib/podcast/podcastIndex.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { PodcastShow, PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
||||
|
||||
// Free-tier Podcast Index API credentials
|
||||
const API_KEY = "VKWWTGY25NVCKYJWHSNY";
|
||||
const API_SECRET = "ves3#2YKqSvp7ZdRSuRhSgdnCLtFP4tEbzFGxAtW";
|
||||
const API_BASE = "https://api.podcastindex.org/api/1.0";
|
||||
|
||||
async function sha1(message: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function apiHeaders(): Promise<Record<string, string>> {
|
||||
const apiHeaderTime = Math.floor(Date.now() / 1000).toString();
|
||||
const hash = await sha1(API_KEY + API_SECRET + apiHeaderTime);
|
||||
return {
|
||||
"X-Auth-Key": API_KEY,
|
||||
"X-Auth-Date": apiHeaderTime,
|
||||
"Authorization": hash,
|
||||
"User-Agent": "Wrystr/1.0",
|
||||
};
|
||||
}
|
||||
|
||||
function mapShow(item: Record<string, unknown>): PodcastShow {
|
||||
return {
|
||||
feedUrl: (item.url as string) ?? "",
|
||||
title: (item.title as string) ?? "",
|
||||
author: (item.author as string) ?? "",
|
||||
artworkUrl: (item.artwork as string) || (item.image as string) || "",
|
||||
description: (item.description as string) ?? "",
|
||||
podcastIndexId: item.id as number,
|
||||
};
|
||||
}
|
||||
|
||||
function extractV4V(value: Record<string, unknown> | undefined): V4VRecipient[] {
|
||||
if (!value) return [];
|
||||
const destinations = value.destinations as Record<string, unknown>[] | undefined;
|
||||
if (!Array.isArray(destinations)) return [];
|
||||
return destinations
|
||||
.filter((d) => d.address)
|
||||
.map((d) => ({
|
||||
name: d.name as string | undefined,
|
||||
type: (d.type as string) ?? "wallet",
|
||||
address: d.address as string,
|
||||
split: Number(d.split) || 0,
|
||||
customKey: d.customKey as string | undefined,
|
||||
customValue: d.customValue as string | undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapEpisode(item: Record<string, unknown>, show?: PodcastShow): PodcastEpisode {
|
||||
return {
|
||||
guid: (item.guid as string) || String(item.id ?? ""),
|
||||
title: (item.title as string) ?? "",
|
||||
enclosureUrl: (item.enclosureUrl as string) ?? "",
|
||||
pubDate: (item.datePublished as number) ?? 0,
|
||||
duration: (item.duration as number) ?? 0,
|
||||
description: (item.description as string) ?? "",
|
||||
artworkUrl: (item.feedImage as string) || (item.image as string) || undefined,
|
||||
showTitle: show?.title ?? (item.feedTitle as string) ?? "",
|
||||
showArtworkUrl: show?.artworkUrl ?? (item.feedImage as string) ?? "",
|
||||
podcastIndexId: item.feedId as number | undefined,
|
||||
value: extractV4V(item.value as Record<string, unknown> | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchPodcasts(query: string): Promise<PodcastShow[]> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/search/byterm?q=${encodeURIComponent(query)}`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return ((data.feeds as Record<string, unknown>[]) ?? []).map(mapShow);
|
||||
}
|
||||
|
||||
export async function getEpisodes(feedId: number): Promise<PodcastEpisode[]> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/episodes/byfeedid?id=${feedId}&max=50`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return ((data.items as Record<string, unknown>[]) ?? []).map((item) => mapEpisode(item));
|
||||
}
|
||||
|
||||
export async function getTrending(): Promise<PodcastShow[]> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/podcasts/trending?max=20&lang=en`, { headers });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return ((data.feeds as Record<string, unknown>[]) ?? []).map(mapShow);
|
||||
}
|
||||
|
||||
export async function getPodcastByFeedUrl(feedUrl: string): Promise<PodcastShow | null> {
|
||||
const headers = await apiHeaders();
|
||||
const res = await fetch(`${API_BASE}/podcasts/byfeedurl?url=${encodeURIComponent(feedUrl)}`, { headers });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.feed) return null;
|
||||
return mapShow(data.feed);
|
||||
}
|
||||
132
src/lib/podcast/v4v.ts
Normal file
132
src/lib/podcast/v4v.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { PodcastEpisode, V4VRecipient } from "../../types/podcast";
|
||||
import { payInvoiceViaNWC } from "../lightning/nwc";
|
||||
|
||||
const LNURL_CACHE: Record<string, string> = {};
|
||||
|
||||
async function fetchLnurlPayInvoice(lud16: string, amountMsats: number): Promise<string | null> {
|
||||
try {
|
||||
const [name, domain] = lud16.split("@");
|
||||
if (!name || !domain) return null;
|
||||
|
||||
// Fetch LNURL-pay endpoint
|
||||
const wellKnownUrl = `https://${domain}/.well-known/lnurlp/${name}`;
|
||||
const cacheKey = wellKnownUrl;
|
||||
|
||||
let callbackUrl = LNURL_CACHE[cacheKey];
|
||||
if (!callbackUrl) {
|
||||
const res = await fetch(wellKnownUrl);
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.callback) return null;
|
||||
callbackUrl = data.callback;
|
||||
LNURL_CACHE[cacheKey] = callbackUrl;
|
||||
}
|
||||
|
||||
// Request invoice
|
||||
const separator = callbackUrl.includes("?") ? "&" : "?";
|
||||
const invoiceRes = await fetch(`${callbackUrl}${separator}amount=${amountMsats}`);
|
||||
if (!invoiceRes.ok) return null;
|
||||
const invoiceData = await invoiceRes.json();
|
||||
return invoiceData.pr ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRecipients(episode: PodcastEpisode): V4VRecipient[] {
|
||||
if (episode.value && episode.value.length > 0) return episode.value;
|
||||
return [];
|
||||
}
|
||||
|
||||
let streamingInterval: number | null = null;
|
||||
let accumulatedSats = 0;
|
||||
let accumulatedMinutes = 0;
|
||||
|
||||
export function startStreaming(
|
||||
episode: PodcastEpisode,
|
||||
satsPerMinute: number,
|
||||
nwcUri: string,
|
||||
onPayment: (amount: number) => void,
|
||||
): number {
|
||||
stopStreaming();
|
||||
|
||||
accumulatedSats = 0;
|
||||
accumulatedMinutes = 0;
|
||||
const recipients = getRecipients(episode);
|
||||
|
||||
if (recipients.length === 0) return -1;
|
||||
|
||||
// Normalize splits to sum to 100
|
||||
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
|
||||
|
||||
streamingInterval = window.setInterval(async () => {
|
||||
accumulatedMinutes += 1;
|
||||
accumulatedSats += satsPerMinute;
|
||||
|
||||
// Accumulate for 5 minutes before paying to avoid rate limits
|
||||
if (accumulatedMinutes < 5 && accumulatedMinutes > 0) return;
|
||||
|
||||
const satsToSend = accumulatedSats;
|
||||
accumulatedSats = 0;
|
||||
accumulatedMinutes = 0;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.address || !recipient.address.includes("@")) continue;
|
||||
|
||||
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
||||
const recipientSats = Math.max(1, Math.round(satsToSend * share));
|
||||
const amountMsats = recipientSats * 1000;
|
||||
|
||||
try {
|
||||
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
|
||||
if (!invoice) continue;
|
||||
await payInvoiceViaNWC(nwcUri, invoice);
|
||||
onPayment(recipientSats);
|
||||
} catch {
|
||||
// Payment failed — silently continue
|
||||
}
|
||||
}
|
||||
}, 60000); // Every 60 seconds
|
||||
|
||||
return streamingInterval;
|
||||
}
|
||||
|
||||
export function stopStreaming() {
|
||||
if (streamingInterval !== null) {
|
||||
clearInterval(streamingInterval);
|
||||
streamingInterval = null;
|
||||
}
|
||||
accumulatedSats = 0;
|
||||
accumulatedMinutes = 0;
|
||||
}
|
||||
|
||||
export async function boost(
|
||||
episode: PodcastEpisode,
|
||||
totalSats: number,
|
||||
nwcUri: string,
|
||||
): Promise<number> {
|
||||
const recipients = getRecipients(episode);
|
||||
if (recipients.length === 0) return 0;
|
||||
|
||||
const totalSplit = recipients.reduce((sum, r) => sum + r.split, 0);
|
||||
let paid = 0;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.address || !recipient.address.includes("@")) continue;
|
||||
|
||||
const share = totalSplit > 0 ? recipient.split / totalSplit : 1 / recipients.length;
|
||||
const recipientSats = Math.max(1, Math.round(totalSats * share));
|
||||
const amountMsats = recipientSats * 1000;
|
||||
|
||||
try {
|
||||
const invoice = await fetchLnurlPayInvoice(recipient.address, amountMsats);
|
||||
if (!invoice) continue;
|
||||
await payInvoiceViaNWC(nwcUri, invoice);
|
||||
paid += recipientSats;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
return paid;
|
||||
}
|
||||
@@ -2,27 +2,40 @@ import { create } from "zustand";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { fetchMentions } from "../lib/nostr";
|
||||
|
||||
const NOTIF_SEEN_KEY = "wrystr_notif_last_seen";
|
||||
const NOTIF_READ_KEY = "wrystr_notif_read_ids";
|
||||
const DM_SEEN_KEY = "wrystr_dm_last_seen";
|
||||
const MAX_NOTIFICATIONS = 15;
|
||||
|
||||
interface NotificationsState {
|
||||
notifications: NDKEvent[];
|
||||
unreadCount: number;
|
||||
lastSeenAt: number;
|
||||
readIds: Set<string>;
|
||||
loading: boolean;
|
||||
currentPubkey: string | null;
|
||||
dmLastSeen: Record<string, number>;
|
||||
dmUnreadCount: number;
|
||||
|
||||
fetchNotifications: (pubkey: string) => Promise<void>;
|
||||
markRead: (eventId: string) => void;
|
||||
markAllRead: () => void;
|
||||
isRead: (eventId: string) => boolean;
|
||||
markDMRead: (partnerPubkey: string) => void;
|
||||
computeDMUnread: (conversations: Array<{ partnerPubkey: string; lastAt: number }>) => void;
|
||||
}
|
||||
|
||||
function loadLastSeen(): number {
|
||||
const stored = parseInt(localStorage.getItem(NOTIF_SEEN_KEY) ?? "0");
|
||||
return stored || Math.floor(Date.now() / 1000) - 86400;
|
||||
function loadReadIds(): Set<string> {
|
||||
try {
|
||||
const arr = JSON.parse(localStorage.getItem(NOTIF_READ_KEY) ?? "[]");
|
||||
return new Set(arr);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveReadIds(ids: Set<string>) {
|
||||
// Only keep the most recent entries to avoid unbounded growth
|
||||
const arr = Array.from(ids).slice(-200);
|
||||
localStorage.setItem(NOTIF_READ_KEY, JSON.stringify(arr));
|
||||
}
|
||||
|
||||
function loadDMLastSeen(): Record<string, number> {
|
||||
@@ -36,7 +49,7 @@ function loadDMLastSeen(): Record<string, number> {
|
||||
export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
notifications: [],
|
||||
unreadCount: 0,
|
||||
lastSeenAt: loadLastSeen(),
|
||||
readIds: loadReadIds(),
|
||||
loading: false,
|
||||
currentPubkey: null,
|
||||
dmLastSeen: loadDMLastSeen(),
|
||||
@@ -50,11 +63,16 @@ export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
}
|
||||
set({ loading: true });
|
||||
try {
|
||||
const lastSeenAt = isNewAccount ? loadLastSeen() : get().lastSeenAt;
|
||||
const events = await fetchMentions(pubkey, lastSeenAt);
|
||||
const newEvents = events.filter((e) => (e.created_at ?? 0) > lastSeenAt);
|
||||
const unreadCount = newEvents.length;
|
||||
set({ notifications: events, unreadCount, lastSeenAt });
|
||||
// Always fetch recent notifications (last 7 days), keep up to MAX_NOTIFICATIONS
|
||||
const since = Math.floor(Date.now() / 1000) - 7 * 86400;
|
||||
// Fetch more than we need since we filter out own events
|
||||
const events = await fetchMentions(pubkey, since, MAX_NOTIFICATIONS * 3);
|
||||
// Filter out own events — your replies shouldn't be notifications
|
||||
const others = events.filter((e) => e.pubkey !== pubkey);
|
||||
const sorted = others.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)).slice(0, MAX_NOTIFICATIONS);
|
||||
const { readIds } = get();
|
||||
const unreadCount = sorted.filter((e) => !readIds.has(e.id!)).length;
|
||||
set({ notifications: sorted, unreadCount });
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
@@ -62,10 +80,28 @@ export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
markRead: (eventId: string) => {
|
||||
const { readIds, notifications } = get();
|
||||
if (readIds.has(eventId)) return;
|
||||
const updated = new Set(readIds);
|
||||
updated.add(eventId);
|
||||
saveReadIds(updated);
|
||||
const unreadCount = notifications.filter((e) => !updated.has(e.id!)).length;
|
||||
set({ readIds: updated, unreadCount });
|
||||
},
|
||||
|
||||
markAllRead: () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
localStorage.setItem(NOTIF_SEEN_KEY, String(now));
|
||||
set({ lastSeenAt: now, unreadCount: 0 });
|
||||
const { notifications, readIds } = get();
|
||||
const updated = new Set(readIds);
|
||||
for (const e of notifications) {
|
||||
if (e.id) updated.add(e.id);
|
||||
}
|
||||
saveReadIds(updated);
|
||||
set({ readIds: updated, unreadCount: 0 });
|
||||
},
|
||||
|
||||
isRead: (eventId: string) => {
|
||||
return get().readIds.has(eventId);
|
||||
},
|
||||
|
||||
markDMRead: (partnerPubkey: string) => {
|
||||
|
||||
170
src/stores/podcast.ts
Normal file
170
src/stores/podcast.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { create } from "zustand";
|
||||
import type { PodcastEpisode, PlaybackState } from "../types/podcast";
|
||||
|
||||
const STORAGE_KEY = "wrystr_podcast";
|
||||
|
||||
interface EpisodeProgress {
|
||||
position: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function loadPersistedState(): {
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
v4vEnabled: boolean;
|
||||
v4vSatsPerMinute: number;
|
||||
progressMap: Record<string, EpisodeProgress>;
|
||||
} {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { volume: 1, playbackRate: 1, v4vEnabled: false, v4vSatsPerMinute: 10, progressMap: {} };
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { volume: 1, playbackRate: 1, v4vEnabled: false, v4vSatsPerMinute: 10, progressMap: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function persist(partial: Partial<PodcastState>) {
|
||||
try {
|
||||
const prev = loadPersistedState();
|
||||
const next = {
|
||||
volume: partial.volume ?? prev.volume,
|
||||
playbackRate: partial.playbackRate ?? prev.playbackRate,
|
||||
v4vEnabled: partial.v4vEnabled ?? prev.v4vEnabled,
|
||||
v4vSatsPerMinute: partial.v4vSatsPerMinute ?? prev.v4vSatsPerMinute,
|
||||
progressMap: partial.progressMap ?? prev.progressMap,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
interface PodcastState {
|
||||
currentEpisode: PodcastEpisode | null;
|
||||
playbackState: PlaybackState;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
v4vEnabled: boolean;
|
||||
v4vSatsPerMinute: number;
|
||||
v4vTotalStreamed: number;
|
||||
v4vStreaming: boolean;
|
||||
v4vIntervalId: number | null;
|
||||
progressMap: Record<string, EpisodeProgress>;
|
||||
playCounter: number;
|
||||
|
||||
play: (episode: PodcastEpisode) => void;
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
seek: (seconds: number) => void;
|
||||
setRate: (rate: number) => void;
|
||||
setVolume: (v: number) => void;
|
||||
setPlaybackState: (state: PlaybackState) => void;
|
||||
setCurrentTime: (t: number) => void;
|
||||
setDuration: (d: number) => void;
|
||||
saveProgress: () => void;
|
||||
loadProgress: (guid: string) => number;
|
||||
addStreamedSats: (amount: number) => void;
|
||||
setV4VEnabled: (enabled: boolean) => void;
|
||||
setV4VSatsPerMinute: (sats: number) => void;
|
||||
setV4VStreaming: (streaming: boolean, intervalId?: number | null) => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
const persisted = loadPersistedState();
|
||||
|
||||
export const usePodcastStore = create<PodcastState>((set, get) => ({
|
||||
currentEpisode: null,
|
||||
playbackState: "idle",
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
playbackRate: persisted.playbackRate,
|
||||
volume: persisted.volume,
|
||||
v4vEnabled: persisted.v4vEnabled,
|
||||
v4vSatsPerMinute: persisted.v4vSatsPerMinute,
|
||||
v4vTotalStreamed: 0,
|
||||
v4vStreaming: false,
|
||||
v4vIntervalId: null,
|
||||
progressMap: persisted.progressMap,
|
||||
playCounter: 0,
|
||||
|
||||
play: (episode) => {
|
||||
const position = get().loadProgress(episode.guid);
|
||||
set({
|
||||
currentEpisode: episode,
|
||||
playbackState: "loading",
|
||||
currentTime: position,
|
||||
duration: episode.duration || 0,
|
||||
playCounter: get().playCounter + 1,
|
||||
});
|
||||
},
|
||||
|
||||
pause: () => set({ playbackState: "paused" }),
|
||||
|
||||
resume: () => set({ playbackState: "playing" }),
|
||||
|
||||
seek: (seconds) => set({ currentTime: seconds }),
|
||||
|
||||
setRate: (rate) => {
|
||||
set({ playbackRate: rate });
|
||||
persist({ playbackRate: rate });
|
||||
},
|
||||
|
||||
setVolume: (v) => {
|
||||
set({ volume: v });
|
||||
persist({ volume: v });
|
||||
},
|
||||
|
||||
setPlaybackState: (state) => set({ playbackState: state }),
|
||||
|
||||
setCurrentTime: (t) => set({ currentTime: t }),
|
||||
|
||||
setDuration: (d) => set({ duration: d }),
|
||||
|
||||
saveProgress: () => {
|
||||
const { currentEpisode, currentTime, progressMap } = get();
|
||||
if (!currentEpisode) return;
|
||||
const updated = {
|
||||
...progressMap,
|
||||
[currentEpisode.guid]: { position: currentTime, timestamp: Date.now() },
|
||||
};
|
||||
set({ progressMap: updated });
|
||||
persist({ progressMap: updated });
|
||||
},
|
||||
|
||||
loadProgress: (guid) => {
|
||||
const entry = get().progressMap[guid];
|
||||
return entry?.position ?? 0;
|
||||
},
|
||||
|
||||
addStreamedSats: (amount) => set((s) => ({ v4vTotalStreamed: s.v4vTotalStreamed + amount })),
|
||||
|
||||
setV4VEnabled: (enabled) => {
|
||||
set({ v4vEnabled: enabled });
|
||||
persist({ v4vEnabled: enabled });
|
||||
},
|
||||
|
||||
setV4VSatsPerMinute: (sats) => {
|
||||
set({ v4vSatsPerMinute: sats });
|
||||
persist({ v4vSatsPerMinute: sats });
|
||||
},
|
||||
|
||||
setV4VStreaming: (streaming, intervalId) => set({
|
||||
v4vStreaming: streaming,
|
||||
v4vIntervalId: intervalId ?? null,
|
||||
}),
|
||||
|
||||
stop: () => {
|
||||
const { v4vIntervalId } = get();
|
||||
if (v4vIntervalId !== null) clearInterval(v4vIntervalId);
|
||||
get().saveProgress();
|
||||
set({
|
||||
currentEpisode: null,
|
||||
playbackState: "idle",
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
v4vStreaming: false,
|
||||
v4vIntervalId: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "podcasts" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
|
||||
type FeedTab = "global" | "following" | "trending";
|
||||
|
||||
interface UIState {
|
||||
|
||||
33
src/types/podcast.ts
Normal file
33
src/types/podcast.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface PodcastShow {
|
||||
feedUrl: string;
|
||||
title: string;
|
||||
author: string;
|
||||
artworkUrl: string;
|
||||
description: string;
|
||||
podcastIndexId?: number;
|
||||
}
|
||||
|
||||
export interface V4VRecipient {
|
||||
name?: string;
|
||||
type: string;
|
||||
address: string;
|
||||
split: number;
|
||||
customKey?: string;
|
||||
customValue?: string;
|
||||
}
|
||||
|
||||
export interface PodcastEpisode {
|
||||
guid: string;
|
||||
title: string;
|
||||
enclosureUrl: string;
|
||||
pubDate: number;
|
||||
duration: number;
|
||||
description: string;
|
||||
artworkUrl?: string;
|
||||
showTitle: string;
|
||||
showArtworkUrl: string;
|
||||
podcastIndexId?: number;
|
||||
value?: V4VRecipient[];
|
||||
}
|
||||
|
||||
export type PlaybackState = "idle" | "loading" | "playing" | "paused";
|
||||
Reference in New Issue
Block a user