From 960e41e164ef712a4d08da0f6cd95e660c3f2218 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 23 Jul 2026 15:31:01 -0400 Subject: [PATCH] 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 --- neode-ui/src/views/Cloud.vue | 9 +++ neode-ui/src/views/CloudFolder.vue | 11 ++- neode-ui/src/views/PeerFiles.vue | 7 ++ neode-ui/src/views/web5/Web5SharedContent.vue | 69 +++++++++++++++---- 4 files changed, 82 insertions(+), 14 deletions(-) diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue index 090e68ba..88bbb1cd 100644 --- a/neode-ui/src/views/Cloud.vue +++ b/neode-ui/src/views/Cloud.vue @@ -638,6 +638,15 @@ async function handlePlay(path: string, name: string) { } 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. const mediaItems = context.filter(item => { const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '' diff --git a/neode-ui/src/views/CloudFolder.vue b/neode-ui/src/views/CloudFolder.vue index e2a67cf6..67064603 100644 --- a/neode-ui/src/views/CloudFolder.vue +++ b/neode-ui/src/views/CloudFolder.vue @@ -323,9 +323,18 @@ const shareTarget = ref<{ path: string; name: string; isDir: boolean } | null>(n const lightboxIndex = ref(null) 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 // must be the index within that filtered list - const items = cloudStore.sortedItems const mediaItems = items.filter(item => { const ext = item.name.includes('.') ? item.name.split('.').pop()!.toLowerCase() : '' const cat = getFileCategory(ext, item.isDir) diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index c5892b08..d87a5547 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -674,6 +674,13 @@ async function viewOwned(item: CatalogItem) { }) if (!res?.data) { purchaseError.value = res?.error || 'Could not open your purchased file'; return } 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) viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime)) viewerMime.value = mime diff --git a/neode-ui/src/views/web5/Web5SharedContent.vue b/neode-ui/src/views/web5/Web5SharedContent.vue index c472ac68..3712b478 100644 --- a/neode-ui/src/views/web5/Web5SharedContent.vue +++ b/neode-ui/src/views/web5/Web5SharedContent.vue @@ -235,7 +235,7 @@ + + + + + +
@@ -550,6 +585,26 @@ function downloadPeerContent(item: PeerContentItem) { 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(null) +const pendingBalance = ref(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) { if (!browsePeerOnion.value || purchasingId.value) return const price = getItemPrice(item.access) @@ -557,18 +612,6 @@ async function purchaseAndDownload(item: PeerContentItem) { purchasingId.value = item.id 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 }>({ method: 'content.download-peer-paid', params: { onion: browsePeerOnion.value, content_id: item.id, price_sats: price },