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

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

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

View File

@@ -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;

View 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
View File

@@ -0,0 +1,2 @@
export { searchPodcasts, getEpisodes, getTrending, getPodcastByFeedUrl } from "./podcastIndex";
export { resolveFountainEpisode, FOUNTAIN_REGEX } from "./fountainFm";

View 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
View 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;
}