feat(content): seller-picked payment methods + music always in the bottom bar + video PiP

- Paid sharing: AccessControl::Paid gains an accepted-methods list
  (lightning/onchain/ecash/fedimint; empty = all, back-compat). Sellers pick
  methods in ShareModal, gated on what the node can actually receive (LND
  running/channel open, ecash wallet, fedimint joined) with an ⓘ that
  explains exactly how to enable a missing rail. Enforced server-side (the
  invoice/onchain mints refuse non-accepted methods; the serve gate only
  honors tokens/hashes for accepted rails) and the buyer's pay modal only
  offers what the seller accepts.
- Purchased music: the two remaining lightbox paths now use the bottom-bar
  player — the immediate post-ecash-purchase viewer and the Paid Files
  tab's window.open.
- Picture-in-picture buttons on the peer video player and the cloud media
  lightbox (utils/pip.ts; Chromium/Safari, no-op elsewhere).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-23 16:15:11 -04:00
co-authored by Claude Fable 5
parent d5fc3d01a4
commit f72d4b92ac
8 changed files with 299 additions and 23 deletions
+8 -1
View File
@@ -445,7 +445,14 @@ async function viewPaidItem(it: PaidItem) {
const bin = atob(b64)
const arr = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
const url = URL.createObjectURL(new Blob([arr], { type: res.mime_type || it.mime_type }))
const mime = res.mime_type || it.mime_type
const url = URL.createObjectURL(new Blob([arr], { type: mime }))
// Music ALWAYS plays in the global bottom-bar player — never a popup/
// lightbox (blob URL stays alive for the bar; it owns playback now).
if (mime.startsWith('audio/')) {
audioPlayer.play(url, it.filename.split('/').pop() || it.filename)
return
}
window.open(url, '_blank', 'noopener')
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
+54 -9
View File
@@ -251,9 +251,22 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Picture-in-picture -->
<button
v-if="pipSupported"
class="absolute -top-10 right-10 text-white/60 hover:text-white transition-colors"
title="Picture-in-picture"
@click="togglePip(peerVideoRef)"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="5" width="18" height="14" rx="2" stroke-width="2" />
<rect x="12" y="12" width="7" height="5" rx="1" stroke-width="2" />
</svg>
</button>
<!-- Video element -->
<div class="relative">
<video
ref="peerVideoRef"
:src="videoPlayerUrl"
class="w-full rounded-xl bg-black"
controls
@@ -378,9 +391,11 @@
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
</p>
<!-- Step 1: choose a payment method -->
<!-- Step 1: choose a payment method — only the methods the SELLER
accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3">
<button
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="ecashPreparing || downloading === payItem.id"
@click="prepareEcashPay"
@@ -395,6 +410,7 @@
</button>
<button
v-if="acceptsMethod(payItem.access, 'lightning')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying"
@click="payWithLightning"
@@ -409,6 +425,7 @@
</button>
<button
v-if="acceptsMethod(payItem.access, 'lightning') || acceptsMethod(payItem.access, 'onchain')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
@click="openQrPay"
@@ -423,6 +440,7 @@
</button>
<button
v-if="acceptsMethod(payItem.access, 'onchain')"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="lnPaying || onchainPaying"
@click="payOnchain"
@@ -486,10 +504,11 @@
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
<div v-else>
<!-- Method tabs, styled like the wallet Send/Receive modal -->
<!-- Method tabs, styled like the wallet Send/Receive modal;
only tabs the seller accepts are shown -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['onchain', 'lightning'] as const)"
v-for="m in (['onchain', 'lightning'] as const).filter(m => acceptsMethod(payItem!.access, m))"
:key="m"
@click="selectQrTab(m)"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
@@ -580,6 +599,7 @@ import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { pipSupported, togglePip } from '@/utils/pip'
import BackButton from '@/components/BackButton.vue'
const props = defineProps<{
@@ -753,6 +773,7 @@ let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
// Video player modal state
const peerVideoRef = ref<HTMLVideoElement | null>(null)
const videoPlayerItem = ref<CatalogItem | null>(null)
const videoPlayerUrl = ref<string | null>(null)
const videoPlayerPaid = ref(false)
@@ -910,6 +931,15 @@ function getItemPrice(access: CatalogItem['access']): number {
return 0
}
/** Payment methods the seller accepts for this item. Missing/empty = all
* (items shared before sellers could restrict methods). */
function acceptsMethod(access: CatalogItem['access'], method: string): boolean {
if (typeof access !== 'object' || !('paid' in access)) return true
const accepted = (access.paid as { accepted?: string[] }).accepted
if (!Array.isArray(accepted) || accepted.length === 0) return true
return accepted.includes(method)
}
async function downloadFile(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
@@ -988,7 +1018,6 @@ function closePayModal() {
*/
function openQrPay() {
payMode.value = 'qr'
qrTab.value = 'onchain'
invoiceData.value = null
invoiceQr.value = ''
invoiceError.value = ''
@@ -996,7 +1025,14 @@ function openQrPay() {
onchainData.value = null
onchainQr.value = ''
onchainError.value = ''
loadOnchainQr()
// Start on the first tab the seller actually accepts.
if (payItem.value && !acceptsMethod(payItem.value.access, 'onchain')) {
qrTab.value = 'lightning'
payWithInvoice()
} else {
qrTab.value = 'onchain'
loadOnchainQr()
}
}
/** Switch QR tab, lazily loading that method's QR the first time it's shown and
@@ -1219,10 +1255,19 @@ async function confirmEcashPay() {
// forward; the viewer offers a Save button for an explicit download.
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = result.mime_type || item.mime_type
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
viewerMime.value = mime
viewerItem.value = item
// A just-bought song goes straight to the bottom-bar player — the
// owned-content lightbox is for images/video only.
if (mime.startsWith('audio/')) {
audioPlayer.play(
URL.createObjectURL(base64ToBlob(result.data, mime)),
item.filename.split('/').pop() || item.filename,
)
} else {
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
viewerMime.value = mime
viewerItem.value = item
}
closePayModal()
void loadOwned()
} else if (result?.error) {