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
@@ -17,11 +17,24 @@
|
||||
</span>
|
||||
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
|
||||
</div>
|
||||
<button class="lightbox-btn" @click="close">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
|
||||
class="lightbox-btn"
|
||||
title="Picture-in-picture"
|
||||
@click.stop="togglePip(videoEl)"
|
||||
>
|
||||
<svg class="w-5 h-5" 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>
|
||||
<button class="lightbox-btn" @click="close">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
@@ -111,6 +124,7 @@
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||
import { getFileCategory } from '@/composables/useFileType'
|
||||
import { pipSupported, togglePip } from '@/utils/pip'
|
||||
|
||||
const props = defineProps<{
|
||||
items: FileBrowserItem[]
|
||||
|
||||
@@ -87,6 +87,47 @@
|
||||
/>
|
||||
<span class="share-price-unit">sats</span>
|
||||
</div>
|
||||
|
||||
<!-- Accepted payment methods (only for paid) — gated on what this
|
||||
node can actually receive; unavailable rails are disabled with
|
||||
an ⓘ that explains how to enable them. -->
|
||||
<div v-if="accessType === 'paid'" class="mt-3">
|
||||
<p class="text-xs font-medium text-white/60 uppercase tracking-wider mb-2">Payments you accept</p>
|
||||
<div class="space-y-2">
|
||||
<div v-for="m in PAY_METHODS" :key="m.key" class="share-modal-row">
|
||||
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||
<p class="text-sm text-white/90">{{ m.label }}</p>
|
||||
<span v-if="capability[m.key] === undefined" class="text-[10px] text-white/40">checking…</span>
|
||||
<button
|
||||
v-else-if="!capability[m.key]"
|
||||
class="w-4 h-4 rounded-full bg-white/10 text-white/60 hover:text-white text-[10px] leading-4 text-center shrink-0"
|
||||
title="Why is this unavailable?"
|
||||
@click="adviceFor = m.key"
|
||||
>i</button>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="acceptedSet.has(m.key)"
|
||||
:disabled="!capability[m.key]"
|
||||
:aria-label="`Accept ${m.label}`"
|
||||
@update:model-value="toggleMethod(m.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="acceptedSet.size === 0" class="text-xs text-red-400 mt-2">
|
||||
Pick at least one payment method buyers can use.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advice modal for an unavailable payment method -->
|
||||
<div v-if="adviceFor" class="mt-4 p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="text-sm font-medium text-white">{{ PAY_METHODS.find(m => m.key === adviceFor)?.label }} isn't ready on this node</p>
|
||||
<button class="text-white/50 hover:text-white text-xs" @click="adviceFor = null">Dismiss</button>
|
||||
</div>
|
||||
<ul class="text-xs text-white/60 list-disc pl-4 space-y-1">
|
||||
<li v-for="line in adviceLines[adviceFor] || []" :key="line">{{ line }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Status messages -->
|
||||
@@ -109,7 +150,7 @@
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
|
||||
<button
|
||||
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
|
||||
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1))"
|
||||
:disabled="saving || (shared && accessType === 'paid' && (!priceSats || priceSats < 1 || acceptedSet.size === 0))"
|
||||
@click="save"
|
||||
>
|
||||
{{ shared ? 'Share' : 'Stop Sharing' }}
|
||||
@@ -144,6 +185,89 @@ const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
const successMsg = ref<string | null>(null)
|
||||
|
||||
// --- Accepted payment methods, gated on what this node can actually receive ---
|
||||
|
||||
const PAY_METHODS = [
|
||||
{ key: 'lightning', label: 'Lightning' },
|
||||
{ key: 'onchain', label: 'On-chain' },
|
||||
{ key: 'ecash', label: 'Cashu ecash' },
|
||||
{ key: 'fedimint', label: 'Fedimint' },
|
||||
] as const
|
||||
type PayMethod = (typeof PAY_METHODS)[number]['key']
|
||||
|
||||
// undefined = probe in flight; then true/false per method.
|
||||
const capability = ref<Partial<Record<PayMethod, boolean>>>({})
|
||||
const adviceLines = ref<Partial<Record<PayMethod, string[]>>>({})
|
||||
const acceptedSet = ref<Set<PayMethod>>(new Set())
|
||||
const adviceFor = ref<PayMethod | null>(null)
|
||||
// Only default-select capable methods when the item had no saved list.
|
||||
let acceptedLoadedFromItem = false
|
||||
|
||||
function toggleMethod(key: PayMethod, on: boolean) {
|
||||
const next = new Set(acceptedSet.value)
|
||||
if (on) next.add(key)
|
||||
else next.delete(key)
|
||||
acceptedSet.value = next
|
||||
}
|
||||
|
||||
/** Probe the node's rails and build advice for the unavailable ones. */
|
||||
async function probeCapabilities() {
|
||||
// Lightning + on-chain both live on LND.
|
||||
try {
|
||||
const info = await rpcClient.call<{ num_active_channels?: number; synced_to_chain?: boolean }>({
|
||||
method: 'lnd.getinfo', timeout: 8000,
|
||||
})
|
||||
capability.value.onchain = true
|
||||
const channels = info?.num_active_channels ?? 0
|
||||
capability.value.lightning = channels > 0
|
||||
if (channels === 0) {
|
||||
adviceLines.value.lightning = [
|
||||
'Your Lightning node is running but has no active channel — buyers cannot pay you over Lightning yet.',
|
||||
'Open a channel from Wallet → Lightning Channels (funds on your on-chain balance can back it).',
|
||||
'Once the channel is active, come back and enable Lightning here.',
|
||||
]
|
||||
}
|
||||
} catch {
|
||||
capability.value.lightning = false
|
||||
capability.value.onchain = false
|
||||
adviceLines.value.lightning = [
|
||||
'The Lightning (LND) app isn\'t running on this node.',
|
||||
'Install/start Lightning from the App Store, let it sync, then open a channel.',
|
||||
]
|
||||
adviceLines.value.onchain = [
|
||||
'On-chain receiving uses the Lightning (LND) app\'s wallet, which isn\'t running.',
|
||||
'Install/start Lightning from the App Store — no channel needed for on-chain.',
|
||||
]
|
||||
}
|
||||
try {
|
||||
await rpcClient.call({ method: 'wallet.ecash-balance', timeout: 8000 })
|
||||
capability.value.ecash = true
|
||||
} catch {
|
||||
capability.value.ecash = false
|
||||
adviceLines.value.ecash = [
|
||||
'The Cashu ecash wallet isn\'t set up on this node.',
|
||||
'Open Wallet → Ecash to connect a mint, then enable Cashu here.',
|
||||
]
|
||||
}
|
||||
try {
|
||||
await rpcClient.call({ method: 'wallet.fedimint-balance', timeout: 8000 })
|
||||
capability.value.fedimint = true
|
||||
} catch {
|
||||
capability.value.fedimint = false
|
||||
adviceLines.value.fedimint = [
|
||||
'This node hasn\'t joined a Fedimint federation.',
|
||||
'Install the Fedimint app and join (or create) a federation, then enable it here.',
|
||||
]
|
||||
}
|
||||
// Defaults: everything the node can receive — unless the item already
|
||||
// carried an explicit list. Never auto-enable an incapable rail.
|
||||
if (!acceptedLoadedFromItem) {
|
||||
acceptedSet.value = new Set(PAY_METHODS.filter((m) => capability.value[m.key]).map((m) => m.key))
|
||||
} else {
|
||||
acceptedSet.value = new Set([...acceptedSet.value].filter((k) => capability.value[k]))
|
||||
}
|
||||
}
|
||||
|
||||
// If we have an existing item, load its state
|
||||
|
||||
/** Catalog entries store the slash-stripped path; props carry a leading
|
||||
@@ -163,7 +287,7 @@ onMounted(async () => {
|
||||
const res = await rpcClient.call<{ items: Array<{
|
||||
id: string
|
||||
filename: string
|
||||
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number } } | string
|
||||
access: { free?: unknown; peersonly?: unknown; paid?: { price_sats: number; accepted?: string[] } } | string
|
||||
availability: string | { allpeers?: unknown; nobody?: unknown }
|
||||
}> }>({ method: 'content.list-mine' })
|
||||
const match = res.items.find(
|
||||
@@ -179,6 +303,14 @@ onMounted(async () => {
|
||||
if ('paid' in access && access.paid) {
|
||||
accessType.value = 'paid'
|
||||
priceSats.value = access.paid.price_sats || 100
|
||||
if (Array.isArray(access.paid.accepted) && access.paid.accepted.length) {
|
||||
acceptedLoadedFromItem = true
|
||||
acceptedSet.value = new Set(
|
||||
access.paid.accepted.filter((m): m is PayMethod =>
|
||||
PAY_METHODS.some((p) => p.key === m),
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if ('peersonly' in access) {
|
||||
accessType.value = 'peers_only'
|
||||
}
|
||||
@@ -187,6 +319,7 @@ onMounted(async () => {
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
|
||||
}
|
||||
void probeCapabilities()
|
||||
})
|
||||
|
||||
async function save() {
|
||||
@@ -240,6 +373,7 @@ async function save() {
|
||||
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
||||
if (accessType.value === 'paid') {
|
||||
pricingParams.price_sats = priceSats.value
|
||||
pricingParams.accepted_methods = [...acceptedSet.value]
|
||||
}
|
||||
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user