feat: podsteadr-player — minimal browse/pay/play client for podsteadr feeds
A separate, lightweight companion app: add any podsteadr server's URL, browse its catalog and feeds, play free episodes directly, and buy priced ones (NIP-07 signing, Lightning invoice + QR, then playback via the paywall-gated download-url endpoint). No backend of its own — pure static SPA, fetches other podsteadr instances directly. Dark glassmorphism theme matching Archipelago's own dashboard (dark background, translucent blurred panels, orange accent). Vite's base path is configurable via VITE_BASE (build arg / .env, not hardcoded) so the same image can be deployed either at a dedicated port's root (no reverse-proxy prefix) or under a subpath behind a shared HTTPS domain, depending on the host. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# ---- build ----
|
||||||
|
FROM docker.io/library/node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
# Root deploys (own port, no reverse-proxy prefix) leave this unset and default to '/'.
|
||||||
|
ARG VITE_BASE=/
|
||||||
|
ENV VITE_BASE=$VITE_BASE
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
FROM docker.io/library/nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /build/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 8097
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:8097/ >/dev/null || exit 1
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name: podsteadr-player
|
||||||
|
|
||||||
|
services:
|
||||||
|
podsteadr-player:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VITE_BASE: ${VITE_BASE}
|
||||||
|
container_name: podsteadr-player
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8097:8097"
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>podsteadr player</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
server {
|
||||||
|
listen 8097;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Pure client-side SPA — no server routes of its own, everything falls back to index.html.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2874
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "podsteadr-player",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -p tsconfig.json && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.0",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "^7.2.0",
|
||||||
|
"vue-tsc": "^3.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
+277
@@ -0,0 +1,277 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { hasNip07 } from './lib/nip07';
|
||||||
|
import { parseCatalogOpml, type CatalogEntry } from './lib/opml';
|
||||||
|
import { parseFeedXml, type PlayerEpisode, type PlayerFeed } from './lib/rss';
|
||||||
|
import {
|
||||||
|
PaymentRequiredError,
|
||||||
|
confirmPurchase,
|
||||||
|
fetchCatalog,
|
||||||
|
fetchDownloadUrl,
|
||||||
|
fetchFeed,
|
||||||
|
requestPurchase,
|
||||||
|
type PurchaseQuote,
|
||||||
|
} from './lib/api';
|
||||||
|
import {
|
||||||
|
addSubscription,
|
||||||
|
findPurchase,
|
||||||
|
loadSubscriptions,
|
||||||
|
removeSubscription,
|
||||||
|
savePurchase,
|
||||||
|
} from './lib/storage';
|
||||||
|
import PurchaseModal from './components/PurchaseModal.vue';
|
||||||
|
import Player from './components/Player.vue';
|
||||||
|
|
||||||
|
const subscriptions = ref(loadSubscriptions());
|
||||||
|
const newOrigin = ref('');
|
||||||
|
const activeOrigin = ref<string | null>(null);
|
||||||
|
|
||||||
|
const catalog = ref<CatalogEntry[]>([]);
|
||||||
|
const catalogLoading = ref(false);
|
||||||
|
const catalogError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const activePodcast = ref<CatalogEntry | null>(null);
|
||||||
|
const feed = ref<PlayerFeed | null>(null);
|
||||||
|
const feedLoading = ref(false);
|
||||||
|
const feedError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const nowPlaying = ref<{ url: string; title: string } | null>(null);
|
||||||
|
|
||||||
|
interface PurchaseState {
|
||||||
|
episode: PlayerEpisode;
|
||||||
|
quote: PurchaseQuote;
|
||||||
|
checking: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
const purchase = ref<PurchaseState | null>(null);
|
||||||
|
const episodeBusy = reactive<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
function normalizeOrigin(input: string): string | null {
|
||||||
|
const trimmed = input.trim().replace(/\/+$/, '');
|
||||||
|
if (!trimmed) return null;
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new URL(trimmed);
|
||||||
|
return trimmed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectServer(origin: string): Promise<void> {
|
||||||
|
activeOrigin.value = origin;
|
||||||
|
activePodcast.value = null;
|
||||||
|
feed.value = null;
|
||||||
|
catalog.value = [];
|
||||||
|
catalogError.value = null;
|
||||||
|
catalogLoading.value = true;
|
||||||
|
try {
|
||||||
|
const xml = await fetchCatalog(origin);
|
||||||
|
catalog.value = parseCatalogOpml(xml);
|
||||||
|
} catch (err) {
|
||||||
|
catalogError.value = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
catalogLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addServer(): Promise<void> {
|
||||||
|
const origin = normalizeOrigin(newOrigin.value);
|
||||||
|
if (!origin) {
|
||||||
|
catalogError.value = 'enter a full URL, e.g. https://example.com or http://host:8095';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subscriptions.value = addSubscription(origin);
|
||||||
|
newOrigin.value = '';
|
||||||
|
await selectServer(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeServer(origin: string): void {
|
||||||
|
subscriptions.value = removeSubscription(origin);
|
||||||
|
if (activeOrigin.value === origin) {
|
||||||
|
activeOrigin.value = null;
|
||||||
|
catalog.value = [];
|
||||||
|
activePodcast.value = null;
|
||||||
|
feed.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectPodcast(entry: CatalogEntry): Promise<void> {
|
||||||
|
activePodcast.value = entry;
|
||||||
|
feed.value = null;
|
||||||
|
feedError.value = null;
|
||||||
|
feedLoading.value = true;
|
||||||
|
try {
|
||||||
|
const xml = await fetchFeed(activeOrigin.value!, entry.podcastId);
|
||||||
|
feed.value = parseFeedXml(xml);
|
||||||
|
} catch (err) {
|
||||||
|
feedError.value = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
feedLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(url: string, title: string): void {
|
||||||
|
nowPlaying.value = { url, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playOrBuy(episode: PlayerEpisode): Promise<void> {
|
||||||
|
if (!episode.priced) {
|
||||||
|
play(episode.enclosureUrl!, episode.title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const origin = activeOrigin.value!;
|
||||||
|
const podcastId = activePodcast.value!.podcastId;
|
||||||
|
const episodeId = episode.episodeId!;
|
||||||
|
|
||||||
|
const cached = findPurchase(origin, podcastId, episodeId);
|
||||||
|
if (cached) {
|
||||||
|
play(cached.url, episode.title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNip07()) {
|
||||||
|
feedError.value = 'This episode is priced — install a NIP-07 Nostr extension (e.g. Alby, nos2x) to buy it.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeBusy[episode.guid] = true;
|
||||||
|
feedError.value = null;
|
||||||
|
try {
|
||||||
|
const url = await fetchDownloadUrl(origin, podcastId, episodeId);
|
||||||
|
savePurchase({ origin, podcastId, episodeId, url });
|
||||||
|
play(url, episode.title);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof PaymentRequiredError) {
|
||||||
|
try {
|
||||||
|
const quote = await requestPurchase(origin, podcastId, episodeId);
|
||||||
|
purchase.value = { episode, quote, checking: false, error: null };
|
||||||
|
} catch (purchaseErr) {
|
||||||
|
feedError.value = (purchaseErr as Error).message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
feedError.value = (err as Error).message;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
episodeBusy[episode.guid] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkPayment(): Promise<void> {
|
||||||
|
const state = purchase.value;
|
||||||
|
if (!state) return;
|
||||||
|
state.checking = true;
|
||||||
|
state.error = null;
|
||||||
|
const { episode, quote } = state;
|
||||||
|
const origin = activeOrigin.value!;
|
||||||
|
const podcastId = activePodcast.value!.podcastId;
|
||||||
|
const episodeId = episode.episodeId!;
|
||||||
|
try {
|
||||||
|
const paid = await confirmPurchase(origin, podcastId, episodeId, quote.quoteId);
|
||||||
|
if (!paid) {
|
||||||
|
state.error = 'Invoice not paid yet.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = await fetchDownloadUrl(origin, podcastId, episodeId);
|
||||||
|
savePurchase({ origin, podcastId, episodeId, url });
|
||||||
|
play(url, episode.title);
|
||||||
|
purchase.value = null;
|
||||||
|
} catch (err) {
|
||||||
|
state.error = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
state.checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nip07Available = computed(() => hasNip07());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6 p-4 pb-32">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-2xl font-bold text-white">podsteadr player</h1>
|
||||||
|
<p class="text-sm text-white/60">Browse, pay, and play podsteadr feeds.</p>
|
||||||
|
<p v-if="!nip07Available" class="mt-1 text-xs text-orange-400">
|
||||||
|
No NIP-07 extension detected — free episodes still play, but buying priced ones needs one.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card space-y-3">
|
||||||
|
<h2 class="label">Servers</h2>
|
||||||
|
<form class="flex gap-2" @submit.prevent="addServer">
|
||||||
|
<input v-model="newOrigin" class="input" placeholder="https://your-podsteadr-server" />
|
||||||
|
<button type="submit" class="btn-primary shrink-0">Add</button>
|
||||||
|
</form>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="sub in subscriptions"
|
||||||
|
:key="sub.origin"
|
||||||
|
class="flex items-center justify-between rounded-lg px-2 py-1"
|
||||||
|
:class="activeOrigin === sub.origin ? 'bg-white/10' : ''"
|
||||||
|
>
|
||||||
|
<button type="button" class="truncate text-left text-sm text-white/90" @click="selectServer(sub.origin)">
|
||||||
|
{{ sub.origin }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="text-xs text-white/30 hover:text-red-400" @click="removeServer(sub.origin)">
|
||||||
|
remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activeOrigin" class="card space-y-2">
|
||||||
|
<h2 class="label">Podcasts</h2>
|
||||||
|
<p v-if="catalogLoading" class="text-sm text-white/50">Loading…</p>
|
||||||
|
<p v-else-if="catalogError" class="text-sm text-red-400">{{ catalogError }}</p>
|
||||||
|
<p v-else-if="catalog.length === 0" class="text-sm text-white/50">No podcasts on this server yet.</p>
|
||||||
|
<ul v-else class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="p in catalog"
|
||||||
|
:key="p.podcastId"
|
||||||
|
class="flex items-center justify-between rounded-lg px-2 py-1"
|
||||||
|
:class="activePodcast?.podcastId === p.podcastId ? 'bg-white/10' : ''"
|
||||||
|
>
|
||||||
|
<button type="button" class="truncate text-left text-sm text-white/90" @click="selectPodcast(p)">
|
||||||
|
{{ p.title }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activePodcast" class="card space-y-2">
|
||||||
|
<h2 class="text-lg font-semibold text-white">{{ feed?.title ?? activePodcast.title }}</h2>
|
||||||
|
<p v-if="feed?.description" class="text-sm text-white/60">{{ feed.description }}</p>
|
||||||
|
<p v-if="feedLoading" class="text-sm text-white/50">Loading episodes…</p>
|
||||||
|
<p v-else-if="feedError" class="text-sm text-red-400">{{ feedError }}</p>
|
||||||
|
<ul v-else class="divide-y divide-white/10">
|
||||||
|
<li v-for="ep in feed?.episodes ?? []" :key="ep.guid" class="flex items-center justify-between gap-3 py-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-medium text-white">{{ ep.title }}</p>
|
||||||
|
<p class="truncate text-xs text-white/50">{{ ep.description }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary shrink-0"
|
||||||
|
:disabled="episodeBusy[ep.guid]"
|
||||||
|
@click="playOrBuy(ep)"
|
||||||
|
>
|
||||||
|
{{ episodeBusy[ep.guid] ? '…' : ep.priced ? `Buy ${ep.priceSats ?? ''} sats` : 'Play' }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Player v-if="nowPlaying" :src="nowPlaying.url" :title="nowPlaying.title" class="fixed inset-x-4 bottom-4 mx-auto max-w-2xl" />
|
||||||
|
|
||||||
|
<PurchaseModal
|
||||||
|
v-if="purchase"
|
||||||
|
:episode-title="purchase.episode.title"
|
||||||
|
:amount-sats="purchase.quote.amountSats"
|
||||||
|
:invoice="purchase.quote.invoice"
|
||||||
|
:checking="purchase.checking"
|
||||||
|
:error-message="purchase.error"
|
||||||
|
@check="checkPayment"
|
||||||
|
@close="purchase = null"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
src: string;
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const audio = ref<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.src,
|
||||||
|
async () => {
|
||||||
|
await audio.value?.play().catch(() => {
|
||||||
|
// Autoplay can be blocked — the user still has the visible controls to hit play.
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card sticky bottom-4 space-y-2">
|
||||||
|
<p class="truncate text-sm font-medium text-white">{{ title }}</p>
|
||||||
|
<audio ref="audio" :src="src" controls class="w-full" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watchEffect } from 'vue';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
episodeTitle: string;
|
||||||
|
amountSats: number;
|
||||||
|
invoice: string;
|
||||||
|
checking: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
check: [];
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const qrDataUrl = ref<string | null>(null);
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
qrDataUrl.value = await QRCode.toDataURL(props.invoice, { margin: 1, width: 320 });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyInvoice(): Promise<void> {
|
||||||
|
await navigator.clipboard.writeText(props.invoice);
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => (copied.value = false), 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
|
||||||
|
<div class="card w-full max-w-sm space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Pay {{ amountSats }} sats</h2>
|
||||||
|
<p class="text-sm text-white/60">{{ episodeTitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
|
||||||
|
|
||||||
|
<button type="button" class="btn-secondary w-full break-all text-xs" @click="copyInvoice">
|
||||||
|
{{ copied ? 'Copied!' : invoice }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-white/50">
|
||||||
|
Pay with any Lightning wallet, then tap "I've paid" below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" class="btn-secondary flex-1" @click="emit('close')">Cancel</button>
|
||||||
|
<button type="button" class="btn-primary flex-1" :disabled="checking" @click="emit('check')">
|
||||||
|
{{ checking ? 'Checking…' : "I've paid" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { buildNip98Header } from './nip07';
|
||||||
|
|
||||||
|
export interface PurchaseQuote {
|
||||||
|
quoteId: string;
|
||||||
|
invoice: string;
|
||||||
|
amountSats: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaymentRequiredError extends Error {
|
||||||
|
constructor(public priceSats: number) {
|
||||||
|
super('payment required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function errorMessage(res: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = (await res.json()) as { error?: string };
|
||||||
|
return body.error ?? `request failed: ${res.status}`;
|
||||||
|
} catch {
|
||||||
|
return `request failed: ${res.status}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authedFetch(url: string, method: string, body?: unknown): Promise<Response> {
|
||||||
|
const header = await buildNip98Header(url, method);
|
||||||
|
return fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
authorization: header,
|
||||||
|
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCatalog(origin: string): Promise<string> {
|
||||||
|
const res = await fetch(`${origin}/catalog.opml`);
|
||||||
|
if (!res.ok) throw new Error(`could not fetch catalog: ${res.status}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFeed(origin: string, podcastId: string): Promise<string> {
|
||||||
|
const res = await fetch(`${origin}/feeds/${podcastId}/feed.xml`);
|
||||||
|
if (!res.ok) throw new Error(`could not fetch feed: ${res.status}`);
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestPurchase(
|
||||||
|
origin: string,
|
||||||
|
podcastId: string,
|
||||||
|
episodeId: string,
|
||||||
|
source = 'producer',
|
||||||
|
): Promise<PurchaseQuote> {
|
||||||
|
const url = `${origin}/api/podcasts/${podcastId}/episodes/${episodeId}/purchase`;
|
||||||
|
const res = await authedFetch(url, 'POST', { source });
|
||||||
|
if (!res.ok) throw new Error(await errorMessage(res));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true once the mint has settled the invoice; false means "not paid yet, keep polling". */
|
||||||
|
export async function confirmPurchase(
|
||||||
|
origin: string,
|
||||||
|
podcastId: string,
|
||||||
|
episodeId: string,
|
||||||
|
quoteId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const url = `${origin}/api/podcasts/${podcastId}/episodes/${episodeId}/purchase/${quoteId}/confirm`;
|
||||||
|
const res = await authedFetch(url, 'POST');
|
||||||
|
if (res.status === 402) return false;
|
||||||
|
if (!res.ok) throw new Error(await errorMessage(res));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The paywall gate — resolves to a real playable URL once purchased (or free/owned). */
|
||||||
|
export async function fetchDownloadUrl(origin: string, podcastId: string, episodeId: string): Promise<string> {
|
||||||
|
const url = `${origin}/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`;
|
||||||
|
const header = await buildNip98Header(url, 'GET');
|
||||||
|
const res = await fetch(url, { headers: { authorization: header } });
|
||||||
|
if (res.status === 402) {
|
||||||
|
const body = (await res.json()) as { price_sats: number };
|
||||||
|
throw new PaymentRequiredError(body.price_sats);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(await errorMessage(res));
|
||||||
|
return ((await res.json()) as { url: string }).url;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// NIP-07 browser extension bridge + NIP-98 header construction.
|
||||||
|
// Copied from podsteadr/frontend/src/lib/nip07.ts — same bridge, same header format, so a
|
||||||
|
// purchase/confirm/download-url request signed here verifies identically server-side.
|
||||||
|
|
||||||
|
export interface UnsignedEvent {
|
||||||
|
kind: number;
|
||||||
|
created_at: number;
|
||||||
|
content: string;
|
||||||
|
tags: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedEvent extends UnsignedEvent {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
sig: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Nip07Provider {
|
||||||
|
getPublicKey(): Promise<string>;
|
||||||
|
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
nostr?: Nip07Provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasNip07(): boolean {
|
||||||
|
return typeof window !== 'undefined' && !!window.nostr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nip07(): Nip07Provider {
|
||||||
|
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
|
||||||
|
return window.nostr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
|
||||||
|
export async function buildNip98Header(url: string, method: string): Promise<string> {
|
||||||
|
const event = await nip07().signEvent({
|
||||||
|
kind: 27235,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
content: '',
|
||||||
|
tags: [
|
||||||
|
['u', url],
|
||||||
|
['method', method],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return `Nostr ${btoa(JSON.stringify(event))}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface CatalogEntry {
|
||||||
|
title: string;
|
||||||
|
podcastId: string;
|
||||||
|
feedUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a podsteadr `/catalog.opml` response (services/rss.ts `buildCatalogOpml`). */
|
||||||
|
export function parseCatalogOpml(xml: string): CatalogEntry[] {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
if (doc.querySelector('parsererror')) throw new Error('failed to parse catalog OPML');
|
||||||
|
|
||||||
|
return [...doc.querySelectorAll('outline')].flatMap((o) => {
|
||||||
|
const feedUrl = o.getAttribute('xmlUrl');
|
||||||
|
if (!feedUrl) return [];
|
||||||
|
const podcastId = feedUrl.match(/\/feeds\/([^/]+)\/feed\.xml$/)?.[1];
|
||||||
|
if (!podcastId) return [];
|
||||||
|
return [{ title: o.getAttribute('text') ?? feedUrl, podcastId, feedUrl }];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export interface PlayerEpisode {
|
||||||
|
guid: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
pubDate: string;
|
||||||
|
/** True when the feed points at podsteadr's paywall info page instead of the real file
|
||||||
|
* (services/rss.ts `buildFeedXml`: locked episodes get `enclosure type="text/html"`). */
|
||||||
|
priced: boolean;
|
||||||
|
priceSats?: number;
|
||||||
|
/** The server-side episode id — only recoverable for priced episodes, from the info-page
|
||||||
|
* enclosure URL (`/podcasts/:podcastId/episodes/:episodeId`). Needed to call the
|
||||||
|
* purchase/confirm/download-url API routes. */
|
||||||
|
episodeId?: string;
|
||||||
|
/** Directly playable URL — only set for free episodes. Priced episodes must go through the
|
||||||
|
* purchase flow and `fetchDownloadUrl` instead, even though the source list technically
|
||||||
|
* contains a real URL too (that data is meant for reseller/crawler bookkeeping, not playback). */
|
||||||
|
enclosureUrl?: string;
|
||||||
|
enclosureType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerFeed {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
episodes: PlayerEpisode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(el: Element | null): string {
|
||||||
|
return el?.textContent?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFeedXml(xml: string): PlayerFeed {
|
||||||
|
const doc = new DOMParser().parseFromString(xml, 'application/xml');
|
||||||
|
if (doc.querySelector('parsererror')) throw new Error('failed to parse feed XML');
|
||||||
|
|
||||||
|
const channel = doc.querySelector('channel');
|
||||||
|
if (!channel) throw new Error('no <channel> in feed');
|
||||||
|
|
||||||
|
const title = text(channel.querySelector(':scope > title')) || '(untitled)';
|
||||||
|
const description = text(channel.querySelector(':scope > description'));
|
||||||
|
|
||||||
|
const episodes: PlayerEpisode[] = [...channel.querySelectorAll(':scope > item')].map((item) => {
|
||||||
|
const enclosure = item.querySelector('enclosure');
|
||||||
|
const enclosureUrl = enclosure?.getAttribute('url') ?? '';
|
||||||
|
const enclosureType = enclosure?.getAttribute('type') ?? '';
|
||||||
|
const priced = enclosureType === 'text/html';
|
||||||
|
|
||||||
|
let episodeId: string | undefined;
|
||||||
|
let priceSats: number | undefined;
|
||||||
|
if (priced) {
|
||||||
|
episodeId = enclosureUrl.match(/\/episodes\/([^/?#]+)\/?$/)?.[1];
|
||||||
|
const producerSource = [...item.getElementsByTagName('podsteadr:source')].find(
|
||||||
|
(s) => s.getAttribute('type') === 'producer',
|
||||||
|
);
|
||||||
|
const price = producerSource?.getAttribute('price');
|
||||||
|
priceSats = price ? Number(price) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
guid: text(item.querySelector('guid')) || crypto.randomUUID(),
|
||||||
|
title: text(item.querySelector('title')) || '(untitled)',
|
||||||
|
description: text(item.querySelector('description')),
|
||||||
|
pubDate: text(item.querySelector('pubDate')),
|
||||||
|
priced,
|
||||||
|
priceSats,
|
||||||
|
episodeId,
|
||||||
|
enclosureUrl: priced ? undefined : enclosureUrl,
|
||||||
|
enclosureType: priced ? undefined : enclosureType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { title, description, episodes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const SUBSCRIPTIONS_KEY = 'podsteadr-player:subscriptions';
|
||||||
|
const PURCHASES_KEY = 'podsteadr-player:purchases';
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
origin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseRecord {
|
||||||
|
origin: string;
|
||||||
|
podcastId: string;
|
||||||
|
episodeId: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSubscriptions(): Subscription[] {
|
||||||
|
return readJson<Subscription[]>(SUBSCRIPTIONS_KEY, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addSubscription(origin: string): Subscription[] {
|
||||||
|
const subs = loadSubscriptions();
|
||||||
|
if (!subs.some((s) => s.origin === origin)) subs.push({ origin });
|
||||||
|
localStorage.setItem(SUBSCRIPTIONS_KEY, JSON.stringify(subs));
|
||||||
|
return subs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSubscription(origin: string): Subscription[] {
|
||||||
|
const subs = loadSubscriptions().filter((s) => s.origin !== origin);
|
||||||
|
localStorage.setItem(SUBSCRIPTIONS_KEY, JSON.stringify(subs));
|
||||||
|
return subs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPurchases(): PurchaseRecord[] {
|
||||||
|
return readJson<PurchaseRecord[]>(PURCHASES_KEY, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePurchase(record: PurchaseRecord): void {
|
||||||
|
const list = loadPurchases().filter(
|
||||||
|
(p) => !(p.origin === record.origin && p.podcastId === record.podcastId && p.episodeId === record.episodeId),
|
||||||
|
);
|
||||||
|
list.push(record);
|
||||||
|
localStorage.setItem(PURCHASES_KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPurchase(origin: string, podcastId: string, episodeId: string): PurchaseRecord | undefined {
|
||||||
|
return loadPurchases().find(
|
||||||
|
(p) => p.origin === origin && p.podcastId === podcastId && p.episodeId === episodeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
createApp(App).mount('#app');
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #000;
|
||||||
|
color: white;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 font-medium transition-all disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn border border-white/15 bg-black/60 text-white shadow-lg shadow-black/40 backdrop-blur-xl hover:-translate-y-0.5 hover:bg-black/40 active:translate-y-0;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn border border-white/15 bg-white/5 text-white/80 hover:bg-white/10;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-lg border border-white/15 bg-black/30 px-3 py-2 text-white placeholder-white/30 focus:border-orange-400/60 focus:outline-none focus:ring-1 focus:ring-orange-400/40;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply mb-1 block text-sm font-medium text-white/70;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-2xl border border-white/15 bg-black/50 p-6 shadow-lg shadow-black/40 backdrop-blur-xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{vue,ts}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"jsx": "preserve"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: process.env.VITE_BASE || '/',
|
||||||
|
plugins: [vue()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user