Demo images / Build & push demo images (push) Successful in 3m47s
Cloud.vue's viewPaidItem() called window.open() instead of the in-app MediaLightbox, and its content.owned-get fetch had a 60s timeout with no loading indicator and a swallowed catch. Moves the fetch/decode/route logic into a new usePaidItemViewer composable: image/video route to a second MediaLightbox instance fed a synthetic FileBrowserItem, audio still goes to the global bottom-bar player, and anything with no in-app viewer keeps today's browser-tab fallback. The Paid Files row now shows an "Opening…" spinner (matching PeerFiles' existing treatment) for the fetch's duration, becomes non-interactive to prevent double-fetch, and a real error surfaces through the view's existing alert-error block instead of an empty catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
import { ref } from 'vue'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
import type { FileBrowserItem } from '@/api/filebrowser-client'
|
|
import { useAudioPlayer } from './useAudioPlayer'
|
|
|
|
/**
|
|
* Shape of a purchased item as listed by `content.owned-list` — the minimum
|
|
* fields this composable needs to fetch and route it. Cloud.vue's `PaidItem`
|
|
* interface satisfies this structurally.
|
|
*/
|
|
export interface OwnedItemLike {
|
|
onion: string
|
|
content_id: string
|
|
filename: string
|
|
mime_type: string
|
|
size_bytes: number
|
|
}
|
|
|
|
/** Same key formula the Paid Files row uses for `:key` — one key, one row. */
|
|
export function paidItemKey(it: OwnedItemLike): string {
|
|
return it.onion + it.content_id
|
|
}
|
|
|
|
/**
|
|
* Fetch, decode, route-to-viewer and loading/error state for a purchased
|
|
* item (UIFIX-04 / UIFIX-06).
|
|
*
|
|
* - image/video → routed into the caller's MediaLightbox via `lightboxItems`
|
|
* / `lightboxIndex`, fed a synthetic FileBrowserItem whose blob URL is
|
|
* served back through `resolveBlobUrl`. The lightbox owns revoking that
|
|
* URL on unmount — this composable must never schedule a competing revoke
|
|
* for a URL it has handed to the lightbox.
|
|
* - audio → today's global bottom-bar player, unchanged.
|
|
* - anything else (no in-app viewer) → today's browser-tab fallback,
|
|
* unchanged, including its own revoke timer.
|
|
*/
|
|
export function usePaidItemViewer() {
|
|
const audioPlayer = useAudioPlayer()
|
|
|
|
const opening = ref<string | null>(null)
|
|
const error = ref<string | null>(null)
|
|
const lightboxItems = ref<FileBrowserItem[]>([])
|
|
const lightboxIndex = ref<number | null>(null)
|
|
|
|
// Synthetic-path -> already-fetched blob URL. Populated only for items
|
|
// routed to the lightbox; resolveBlobUrl reads from here and never fetches.
|
|
const urlByPath = new Map<string, string>()
|
|
// One in-flight fetch per item key — a second open() for the same key
|
|
// while the first is pending returns the same promise instead of issuing
|
|
// a second RPC (UIFIX-04 concurrency edge / T-01-65).
|
|
const inFlight = new Map<string, Promise<void>>()
|
|
|
|
async function resolveBlobUrl(path: string): Promise<string> {
|
|
const url = urlByPath.get(path)
|
|
if (!url) throw new Error('Not resolved')
|
|
return url
|
|
}
|
|
|
|
function runOpen(it: OwnedItemLike, key: string): Promise<void> {
|
|
return (async () => {
|
|
opening.value = key
|
|
error.value = null
|
|
try {
|
|
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
|
method: 'content.owned-get',
|
|
params: { onion: it.onion, content_id: it.content_id },
|
|
timeout: 60000,
|
|
})
|
|
const b64 = res.data_base64 || res.data
|
|
if (!b64) {
|
|
error.value = "Couldn't open this file — the peer returned no data."
|
|
return
|
|
}
|
|
const bin = atob(b64)
|
|
const arr = new Uint8Array(bin.length)
|
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i)
|
|
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
|
|
// 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
|
|
}
|
|
|
|
if (mime.startsWith('image/') || mime.startsWith('video/')) {
|
|
const basename = it.filename.split('/').pop() || it.filename
|
|
const path = `paid://${key}`
|
|
urlByPath.set(path, url)
|
|
const synthetic: FileBrowserItem = {
|
|
name: basename,
|
|
path,
|
|
size: it.size_bytes,
|
|
modified: '',
|
|
isDir: false,
|
|
type: mime,
|
|
extension: basename.includes('.') ? basename.split('.').pop()!.toLowerCase() : '',
|
|
}
|
|
lightboxItems.value = [synthetic]
|
|
lightboxIndex.value = 0
|
|
return
|
|
}
|
|
|
|
// No in-app viewer (documents, etc.) — keep today's behaviour.
|
|
window.open(url, '_blank', 'noopener')
|
|
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
|
} catch {
|
|
error.value = "Couldn't open this file — it may be unavailable right now."
|
|
} finally {
|
|
opening.value = null
|
|
inFlight.delete(key)
|
|
}
|
|
})()
|
|
}
|
|
|
|
function open(it: OwnedItemLike): Promise<void> {
|
|
const key = paidItemKey(it)
|
|
const existing = inFlight.get(key)
|
|
if (existing) return existing
|
|
const task = runOpen(it, key)
|
|
inFlight.set(key, task)
|
|
return task
|
|
}
|
|
|
|
function closeLightbox() {
|
|
lightboxIndex.value = null
|
|
}
|
|
|
|
return {
|
|
opening,
|
|
error,
|
|
lightboxItems,
|
|
lightboxIndex,
|
|
resolveBlobUrl,
|
|
open,
|
|
closeLightbox,
|
|
}
|
|
}
|