fix(content): audio always plays in the bottom-bar player + ecash purchase is explicit two-step

- Owned/purchased and cloud audio never opens a lightbox: PeerFiles'
  owned-content viewer and the Cloud/CloudFolder preview route audio to the
  global bottom-bar player (video keeps the lightbox).
- Web5 shared-content 'Buy' no longer fires the node-ecash payment on
  click: it opens a confirmation with balance -> price -> balance-after,
  and pays only on explicit confirm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-23 15:31:01 -04:00
parent 6502f13e29
commit 960e41e164
4 changed files with 82 additions and 14 deletions

View File

@ -638,6 +638,15 @@ async function handlePlay(path: string, name: string) {
} }
function handlePreview(path: string, context: FileBrowserItem[]) { function handlePreview(path: string, context: FileBrowserItem[]) {
// Audio never opens the lightbox it belongs to the bottom-bar player.
const clicked = context.find(item => item.path === path)
if (clicked) {
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
if (getFileCategory(ext, clicked.isDir) === 'audio') {
void handlePlay(path, clicked.name)
return
}
}
// MediaLightbox filters to media internally; index within that filtered list. // MediaLightbox filters to media internally; index within that filtered list.
const mediaItems = context.filter(item => { const mediaItems = context.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '' const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''

View File

@ -323,9 +323,18 @@ const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(n
const lightboxIndex = ref<number | null>(null) const lightboxIndex = ref<number | null>(null)
function handlePreview(path: string) { function handlePreview(path: string) {
const items = cloudStore.sortedItems
// Audio never opens the lightbox it belongs to the bottom-bar player.
const clicked = items.find(item => item.path === path)
if (clicked) {
const ext = clicked.name.includes('.') ? clicked.name.split('.').pop()!.toLowerCase() : ''
if (getFileCategory(ext, clicked.isDir) === 'audio') {
void handlePlay(path, clicked.name)
return
}
}
// MediaLightbox internally filters items to media only, so startIndex // MediaLightbox internally filters items to media only, so startIndex
// must be the index within that filtered list // must be the index within that filtered list
const items = cloudStore.sortedItems
const mediaItems = items.filter(item => { const mediaItems = items.filter(item => {
const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '' const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : ''
const cat = getFileCategory(ext, item.isDir) const cat = getFileCategory(ext, item.isDir)

View File

@ -674,6 +674,13 @@ async function viewOwned(item: CatalogItem) {
}) })
if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return } if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return }
const mime = res.mime_type || item.mime_type const mime = res.mime_type || item.mime_type
// Audio always plays in the global bottom-bar player never the lightbox
// (the blob URL is intentionally not revoked while the bar plays it).
if (mime.startsWith('audio/')) {
const url = URL.createObjectURL(base64ToBlob(res.data, mime))
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
return
}
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value) if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime)) viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
viewerMime.value = mime viewerMime.value = mime

View File

@ -235,7 +235,7 @@
</button> </button>
<button <button
v-else-if="getItemPrice(pItem.access) > 0" v-else-if="getItemPrice(pItem.access) > 0"
@click="purchaseAndDownload(pItem)" @click="requestPurchase(pItem)"
:disabled="purchasingId === pItem.id" :disabled="purchasingId === pItem.id"
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1" class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0 flex items-center gap-1"
> >
@ -305,6 +305,41 @@
</div> </div>
</Teleport> </Teleport>
<!-- Purchase confirmation payment NEVER starts before the user confirms -->
<Teleport to="body">
<div v-if="pendingPurchase" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="pendingPurchase = null">
<div class="glass-card p-6 w-full max-w-sm mx-4" role="dialog" aria-modal="true">
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
<p class="text-sm text-white/70 truncate mb-4">{{ pendingPurchase.filename.split('/').pop() }}</p>
<div class="p-3 bg-white/5 rounded-lg space-y-1.5 mb-4">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Ecash balance</span>
<span class="text-sm font-medium text-white/80">{{ pendingBalance === null ? '…' : pendingBalance.toLocaleString() + ' sats' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Price</span>
<span class="text-sm font-medium text-white/80">{{ getItemPrice(pendingPurchase.access).toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access) ? 'text-red-400' : 'text-white/80'">
{{ pendingBalance === null ? '…' : (pendingBalance - getItemPrice(pendingPurchase.access)).toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)" class="text-xs text-red-400 mb-3">Not enough ecash fund your wallet, or buy from the Peers page to pay another way.</p>
<div class="flex gap-3">
<button @click="pendingPurchase = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button
@click="confirmPendingPurchase"
:disabled="pendingBalance !== null && pendingBalance < getItemPrice(pendingPurchase.access)"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>Pay from node ecash</button>
</div>
</div>
</div>
</Teleport>
<!-- Add Content Modal --> <!-- Add Content Modal -->
<Teleport to="body"> <Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false"> <div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
@ -550,6 +585,26 @@ function downloadPeerContent(item: PeerContentItem) {
safeClipboardWrite(url) safeClipboardWrite(url)
} }
// Purchase is strictly two-step: the Buy click only opens the confirmation
// (with the balance impact); nothing is paid until the user confirms there.
const pendingPurchase = ref<PeerContentItem | null>(null)
const pendingBalance = ref<number | null>(null)
function requestPurchase(item: PeerContentItem) {
if (purchasingId.value) return
pendingPurchase.value = item
pendingBalance.value = null
rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
.then((r) => { pendingBalance.value = r?.balance_sats ?? 0 })
.catch(() => { pendingBalance.value = null })
}
async function confirmPendingPurchase() {
const item = pendingPurchase.value
pendingPurchase.value = null
if (item) await purchaseAndDownload(item)
}
async function purchaseAndDownload(item: PeerContentItem) { async function purchaseAndDownload(item: PeerContentItem) {
if (!browsePeerOnion.value || purchasingId.value) return if (!browsePeerOnion.value || purchasingId.value) return
const price = getItemPrice(item.access) const price = getItemPrice(item.access)
@ -557,18 +612,6 @@ async function purchaseAndDownload(item: PeerContentItem) {
purchasingId.value = item.id purchasingId.value = item.id
try { try {
// Check balance first
try {
const balRes = await rpcClient.call<{ balance_sats?: number }>({ method: 'wallet.ecash-balance' })
const balance = balRes?.balance_sats ?? 0
if (balance < price) {
emit('toast', `Insufficient ecash balance (${balance} sats). Need ${price} sats.`)
return
}
} catch {
// Balance check failed try the purchase anyway
}
const result = await rpcClient.call<{ data?: string; error?: string }>({ const result = await rpcClient.call<{ data?: string; error?: string }>({
method: 'content.download-peer-paid', method: 'content.download-peer-paid',
params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price }, params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },