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>
278 lines
8.8 KiB
Vue
278 lines
8.8 KiB
Vue
<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>
|