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:
parent
d5fc3d01a4
commit
f72d4b92ac
@ -188,7 +188,7 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let price_sats = match &item.access {
|
let price_sats = match &item.access {
|
||||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
content_server::AccessControl::Paid { price_sats, .. } => *price_sats,
|
||||||
_ => {
|
_ => {
|
||||||
// Not a paid item — no invoice to issue.
|
// Not a paid item — no invoice to issue.
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
@ -198,6 +198,13 @@ impl ApiHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if !content_server::method_accepted(&item.access, "lightning") {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(r#"{"error":"The seller does not accept Lightning for this item"}"#),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let memo = format!("Archipelago peer file {content_id}");
|
let memo = format!("Archipelago peer file {content_id}");
|
||||||
match self
|
match self
|
||||||
@ -315,7 +322,18 @@ impl ApiHandler {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
|
||||||
Some(i) => match &i.access {
|
Some(i) => match &i.access {
|
||||||
content_server::AccessControl::Paid { price_sats } => *price_sats,
|
content_server::AccessControl::Paid { price_sats, .. } => {
|
||||||
|
if !content_server::method_accepted(&i.access, "onchain") {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
hyper::Body::from(
|
||||||
|
r#"{"error":"The seller does not accept on-chain payment for this item"}"#,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*price_sats
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
|
|||||||
@ -179,7 +179,24 @@ impl RpcHandler {
|
|||||||
if price == 0 {
|
if price == 0 {
|
||||||
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
|
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
|
||||||
}
|
}
|
||||||
AccessControl::Paid { price_sats: price }
|
// Optional list of payment methods the sharer accepts.
|
||||||
|
// Absent/empty = all methods (backward compatible).
|
||||||
|
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
|
||||||
|
let accepted: Vec<String> = params
|
||||||
|
.get("accepted_methods")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(|m| m.as_str())
|
||||||
|
.filter(|m| KNOWN_METHODS.contains(m))
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
AccessControl::Paid {
|
||||||
|
price_sats: price,
|
||||||
|
accepted,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
|
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -51,9 +51,25 @@ pub enum AccessControl {
|
|||||||
PeersOnly,
|
PeersOnly,
|
||||||
Paid {
|
Paid {
|
||||||
price_sats: u64,
|
price_sats: u64,
|
||||||
|
/// Payment methods the sharer accepts: "lightning", "onchain",
|
||||||
|
/// "ecash", "fedimint". Empty = everything — which is also what
|
||||||
|
/// catalogs written before this field deserialize to.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
accepted: Vec<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does the sharer accept this payment method for the item? Empty list =
|
||||||
|
/// all methods (pre-field catalogs and "no preference").
|
||||||
|
pub fn method_accepted(access: &AccessControl, method: &str) -> bool {
|
||||||
|
match access {
|
||||||
|
AccessControl::Paid { accepted, .. } => {
|
||||||
|
accepted.is_empty() || accepted.iter().any(|m| m == method)
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||||
pub struct ContentCatalog {
|
pub struct ContentCatalog {
|
||||||
pub items: Vec<ContentItem>,
|
pub items: Vec<ContentItem>,
|
||||||
@ -269,20 +285,26 @@ pub async fn serve_content(
|
|||||||
|
|
||||||
// Check access control
|
// Check access control
|
||||||
match &item.access {
|
match &item.access {
|
||||||
AccessControl::Paid { price_sats } => {
|
AccessControl::Paid { price_sats, .. } => {
|
||||||
// Two ways to satisfy payment:
|
// Two ways to satisfy payment:
|
||||||
// (a) a valid ecash token (the local-wallet fast path), or
|
// (a) a valid ecash token (the local-wallet fast path), or
|
||||||
// (b) a Lightning-invoice payment hash this node issued and has
|
// (b) a Lightning-invoice payment hash this node issued and has
|
||||||
// since confirmed settled (the "pay from any wallet" path, #46).
|
// since confirmed settled (the "pay from any wallet" path, #46).
|
||||||
|
// Each path only counts when the sharer accepts that method.
|
||||||
let mut authorized = false;
|
let mut authorized = false;
|
||||||
if let Some(token) = payment_token {
|
if let Some(token) = payment_token {
|
||||||
if verify_payment_token(data_dir, token, *price_sats).await {
|
if (method_accepted(&item.access, "ecash")
|
||||||
|
|| method_accepted(&item.access, "fedimint"))
|
||||||
|
&& verify_payment_token(data_dir, token, *price_sats).await
|
||||||
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !authorized {
|
if !authorized {
|
||||||
if let Some(hash) = invoice_hash {
|
if let Some(hash) = invoice_hash {
|
||||||
if crate::content_invoice::is_paid_for(hash, id).await {
|
if method_accepted(&item.access, "lightning")
|
||||||
|
&& crate::content_invoice::is_paid_for(hash, id).await
|
||||||
|
{
|
||||||
authorized = true;
|
authorized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,11 +17,24 @@
|
|||||||
</span>
|
</span>
|
||||||
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
|
<p class="text-sm text-white/80 truncate">{{ currentItem?.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="lightbox-btn" @click="close">
|
<div class="flex items-center gap-1">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
v-if="pipSupported && currentItem && isVideoFile(currentItem)"
|
||||||
</svg>
|
class="lightbox-btn"
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation arrows -->
|
<!-- Navigation arrows -->
|
||||||
@ -111,6 +124,7 @@
|
|||||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||||
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
||||||
import { getFileCategory } from '@/composables/useFileType'
|
import { getFileCategory } from '@/composables/useFileType'
|
||||||
|
import { pipSupported, togglePip } from '@/utils/pip'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: FileBrowserItem[]
|
items: FileBrowserItem[]
|
||||||
|
|||||||
@ -87,6 +87,47 @@
|
|||||||
/>
|
/>
|
||||||
<span class="share-price-unit">sats</span>
|
<span class="share-price-unit">sats</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Status messages -->
|
<!-- 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-4 py-2 rounded-lg text-sm" @click="$emit('close')">Cancel</button>
|
||||||
<button
|
<button
|
||||||
class="glass-button px-5 py-2 rounded-lg text-sm font-medium share-modal-save"
|
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"
|
@click="save"
|
||||||
>
|
>
|
||||||
{{ shared ? 'Share' : 'Stop Sharing' }}
|
{{ shared ? 'Share' : 'Stop Sharing' }}
|
||||||
@ -144,6 +185,89 @@ const saving = ref(false)
|
|||||||
const errorMsg = ref<string | null>(null)
|
const errorMsg = ref<string | null>(null)
|
||||||
const successMsg = 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
|
// If we have an existing item, load its state
|
||||||
|
|
||||||
/** Catalog entries store the slash-stripped path; props carry a leading
|
/** Catalog entries store the slash-stripped path; props carry a leading
|
||||||
@ -163,7 +287,7 @@ onMounted(async () => {
|
|||||||
const res = await rpcClient.call<{ items: Array<{
|
const res = await rpcClient.call<{ items: Array<{
|
||||||
id: string
|
id: string
|
||||||
filename: 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 }
|
availability: string | { allpeers?: unknown; nobody?: unknown }
|
||||||
}> }>({ method: 'content.list-mine' })
|
}> }>({ method: 'content.list-mine' })
|
||||||
const match = res.items.find(
|
const match = res.items.find(
|
||||||
@ -179,6 +303,14 @@ onMounted(async () => {
|
|||||||
if ('paid' in access && access.paid) {
|
if ('paid' in access && access.paid) {
|
||||||
accessType.value = 'paid'
|
accessType.value = 'paid'
|
||||||
priceSats.value = access.paid.price_sats || 100
|
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) {
|
} else if ('peersonly' in access) {
|
||||||
accessType.value = 'peers_only'
|
accessType.value = 'peers_only'
|
||||||
}
|
}
|
||||||
@ -187,6 +319,7 @@ onMounted(async () => {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
|
if (import.meta.env.DEV) console.warn('Not shared yet, defaults are fine', e)
|
||||||
}
|
}
|
||||||
|
void probeCapabilities()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@ -240,6 +373,7 @@ async function save() {
|
|||||||
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
|
||||||
if (accessType.value === 'paid') {
|
if (accessType.value === 'paid') {
|
||||||
pricingParams.price_sats = priceSats.value
|
pricingParams.price_sats = priceSats.value
|
||||||
|
pricingParams.accepted_methods = [...acceptedSet.value]
|
||||||
}
|
}
|
||||||
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
|
||||||
|
|
||||||
|
|||||||
19
neode-ui/src/utils/pip.ts
Normal file
19
neode-ui/src/utils/pip.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/** Video Picture-in-Picture helpers (Chromium/Safari; no-ops elsewhere).
|
||||||
|
* Kiosk note: PiP is skipped on the WM-less kiosk X session by callers that
|
||||||
|
* care — an unmanaged popup there is unusable (docs/tv-input-iframe-apps.md
|
||||||
|
* sibling investigation, task #18). */
|
||||||
|
|
||||||
|
export const pipSupported =
|
||||||
|
typeof document !== 'undefined' &&
|
||||||
|
'pictureInPictureEnabled' in document &&
|
||||||
|
document.pictureInPictureEnabled
|
||||||
|
|
||||||
|
export async function togglePip(video: HTMLVideoElement | null | undefined): Promise<void> {
|
||||||
|
if (!video || !pipSupported) return
|
||||||
|
try {
|
||||||
|
if (document.pictureInPictureElement === video) await document.exitPictureInPicture()
|
||||||
|
else await video.requestPictureInPicture()
|
||||||
|
} catch {
|
||||||
|
// Permission/transient failure — the button is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -445,7 +445,14 @@ async function viewPaidItem(it: PaidItem) {
|
|||||||
const bin = atob(b64)
|
const bin = atob(b64)
|
||||||
const arr = new Uint8Array(bin.length)
|
const arr = new Uint8Array(bin.length)
|
||||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
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')
|
window.open(url, '_blank', 'noopener')
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
||||||
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
|
} 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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 -->
|
<!-- Video element -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<video
|
<video
|
||||||
|
ref="peerVideoRef"
|
||||||
:src="videoPlayerUrl"
|
:src="videoPlayerUrl"
|
||||||
class="w-full rounded-xl bg-black"
|
class="w-full rounded-xl bg-black"
|
||||||
controls
|
controls
|
||||||
@ -378,9 +391,11 @@
|
|||||||
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
||||||
</p>
|
</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">
|
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||||
<button
|
<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"
|
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"
|
:disabled="ecashPreparing || downloading === payItem.id"
|
||||||
@click="prepareEcashPay"
|
@click="prepareEcashPay"
|
||||||
@ -395,6 +410,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="lnPaying"
|
:disabled="lnPaying"
|
||||||
@click="payWithLightning"
|
@click="payWithLightning"
|
||||||
@ -409,6 +425,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="lnPaying || onchainPaying"
|
:disabled="lnPaying || onchainPaying"
|
||||||
@click="openQrPay"
|
@click="openQrPay"
|
||||||
@ -423,6 +440,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="lnPaying || onchainPaying"
|
:disabled="lnPaying || onchainPaying"
|
||||||
@click="payOnchain"
|
@click="payOnchain"
|
||||||
@ -486,10 +504,11 @@
|
|||||||
|
|
||||||
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
||||||
<div v-else>
|
<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">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<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"
|
:key="m"
|
||||||
@click="selectQrTab(m)"
|
@click="selectQrTab(m)"
|
||||||
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
|
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 QRCode from 'qrcode'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||||
|
import { pipSupported, togglePip } from '@/utils/pip'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -753,6 +773,7 @@ let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// Video player modal state
|
// Video player modal state
|
||||||
|
const peerVideoRef = ref<HTMLVideoElement | null>(null)
|
||||||
const videoPlayerItem = ref<CatalogItem | null>(null)
|
const videoPlayerItem = ref<CatalogItem | null>(null)
|
||||||
const videoPlayerUrl = ref<string | null>(null)
|
const videoPlayerUrl = ref<string | null>(null)
|
||||||
const videoPlayerPaid = ref(false)
|
const videoPlayerPaid = ref(false)
|
||||||
@ -910,6 +931,15 @@ function getItemPrice(access: CatalogItem['access']): number {
|
|||||||
return 0
|
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) {
|
async function downloadFile(item: CatalogItem) {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!onion) return
|
if (!onion) return
|
||||||
@ -988,7 +1018,6 @@ function closePayModal() {
|
|||||||
*/
|
*/
|
||||||
function openQrPay() {
|
function openQrPay() {
|
||||||
payMode.value = 'qr'
|
payMode.value = 'qr'
|
||||||
qrTab.value = 'onchain'
|
|
||||||
invoiceData.value = null
|
invoiceData.value = null
|
||||||
invoiceQr.value = ''
|
invoiceQr.value = ''
|
||||||
invoiceError.value = ''
|
invoiceError.value = ''
|
||||||
@ -996,7 +1025,14 @@ function openQrPay() {
|
|||||||
onchainData.value = null
|
onchainData.value = null
|
||||||
onchainQr.value = ''
|
onchainQr.value = ''
|
||||||
onchainError.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
|
/** 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.
|
// forward; the viewer offers a Save button for an explicit download.
|
||||||
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
|
||||||
const mime = result.mime_type || item.mime_type
|
const mime = result.mime_type || item.mime_type
|
||||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
// A just-bought song goes straight to the bottom-bar player — the
|
||||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
|
// owned-content lightbox is for images/video only.
|
||||||
viewerMime.value = mime
|
if (mime.startsWith('audio/')) {
|
||||||
viewerItem.value = item
|
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()
|
closePayModal()
|
||||||
void loadOwned()
|
void loadOwned()
|
||||||
} else if (result?.error) {
|
} else if (result?.error) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user