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:
co-authored by
Claude Fable 5
parent
d5fc3d01a4
commit
f72d4b92ac
@@ -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 */ }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user