fix(01-14): route Paid Files pictures/videos into the app lightbox with a visible wait (UIFIX-04/UIFIX-06)
Demo images / Build & push demo images (push) Successful in 3m47s
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3288a02df8
commit
bc9a210c75
@@ -0,0 +1,189 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
// ── Module boundary stubs (per plan: jsdom has no real blob decoding —
|
||||||
|
// assert on what was requested and what was routed where, not byte content) ──
|
||||||
|
vi.mock('@/api/rpc-client', () => ({
|
||||||
|
rpcClient: {
|
||||||
|
call: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const playMock = vi.fn()
|
||||||
|
vi.mock('../useAudioPlayer', () => ({
|
||||||
|
useAudioPlayer: () => ({ play: playMock }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { usePaidItemViewer, paidItemKey, type OwnedItemLike } from '../usePaidItemViewer'
|
||||||
|
|
||||||
|
const mockedRpc = vi.mocked(rpcClient)
|
||||||
|
|
||||||
|
const IMAGE_ITEM: OwnedItemLike = {
|
||||||
|
onion: 'abc123.onion',
|
||||||
|
content_id: 'content-1',
|
||||||
|
filename: 'photos/sunset.jpg',
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
size_bytes: 2048,
|
||||||
|
}
|
||||||
|
const VIDEO_ITEM: OwnedItemLike = {
|
||||||
|
onion: 'abc123.onion',
|
||||||
|
content_id: 'content-2',
|
||||||
|
filename: 'clips/holiday.mp4',
|
||||||
|
mime_type: 'video/mp4',
|
||||||
|
size_bytes: 4096,
|
||||||
|
}
|
||||||
|
const AUDIO_ITEM: OwnedItemLike = {
|
||||||
|
onion: 'abc123.onion',
|
||||||
|
content_id: 'content-3',
|
||||||
|
filename: 'music/track.mp3',
|
||||||
|
mime_type: 'audio/mpeg',
|
||||||
|
size_bytes: 1024,
|
||||||
|
}
|
||||||
|
const DOC_ITEM: OwnedItemLike = {
|
||||||
|
onion: 'abc123.onion',
|
||||||
|
content_id: 'content-4',
|
||||||
|
filename: 'docs/invoice.pdf',
|
||||||
|
mime_type: 'application/pdf',
|
||||||
|
size_bytes: 512,
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (v: T) => void
|
||||||
|
let reject!: (e: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/error)', () => {
|
||||||
|
let createObjectURLSpy: ReturnType<typeof vi.fn>
|
||||||
|
let revokeObjectURLSpy: ReturnType<typeof vi.fn>
|
||||||
|
let windowOpenSpy: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.useFakeTimers()
|
||||||
|
createObjectURLSpy = vi.fn(() => 'blob:mock-url')
|
||||||
|
revokeObjectURLSpy = vi.fn()
|
||||||
|
URL.createObjectURL = createObjectURLSpy as unknown as typeof URL.createObjectURL
|
||||||
|
URL.revokeObjectURL = revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
|
||||||
|
windowOpenSpy = vi.fn()
|
||||||
|
window.open = windowOpenSpy as unknown as typeof window.open
|
||||||
|
// atob is provided by jsdom; stub it to avoid depending on real base64 semantics.
|
||||||
|
vi.stubGlobal('atob', vi.fn(() => 'binarydata'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes an image mime to the lightbox, not window.open', async () => {
|
||||||
|
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
await viewer.open(IMAGE_ITEM)
|
||||||
|
|
||||||
|
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||||
|
expect(viewer.lightboxIndex.value).toBe(0)
|
||||||
|
expect(viewer.lightboxItems.value).toHaveLength(1)
|
||||||
|
expect(viewer.error.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes a video mime to the lightbox, not window.open', async () => {
|
||||||
|
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'video/mp4' })
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
await viewer.open(VIDEO_ITEM)
|
||||||
|
|
||||||
|
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||||
|
expect(viewer.lightboxIndex.value).toBe(0)
|
||||||
|
expect(viewer.lightboxItems.value[0].name).toBe('holiday.mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes an audio mime to the audio player, never the lightbox', async () => {
|
||||||
|
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'audio/mpeg' })
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
await viewer.open(AUDIO_ITEM)
|
||||||
|
|
||||||
|
expect(playMock).toHaveBeenCalledWith('blob:mock-url', 'track.mp3')
|
||||||
|
expect(viewer.lightboxIndex.value).toBeNull()
|
||||||
|
expect(windowOpenSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the browser tab for a mime with no in-app viewer', async () => {
|
||||||
|
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'application/pdf' })
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
await viewer.open(DOC_ITEM)
|
||||||
|
|
||||||
|
expect(windowOpenSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noopener')
|
||||||
|
expect(viewer.lightboxIndex.value).toBeNull()
|
||||||
|
// Existing revoke timer for the browser-tab path is untouched.
|
||||||
|
vi.advanceTimersByTime(60000)
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the synthetic lightbox item name carries the real extension', async () => {
|
||||||
|
mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
await viewer.open(IMAGE_ITEM)
|
||||||
|
|
||||||
|
expect(viewer.lightboxItems.value[0].name.endsWith('.jpg')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets opening for the whole duration of the fetch and clears it on success', async () => {
|
||||||
|
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||||
|
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
const p = viewer.open(IMAGE_ITEM)
|
||||||
|
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||||
|
|
||||||
|
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||||
|
await p
|
||||||
|
|
||||||
|
expect(viewer.opening.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears opening and does not derive it from a background-refresh flag — it is driven only by the fetch in flight', async () => {
|
||||||
|
// No cached-resource / refreshing concept is wired into this composable at
|
||||||
|
// all: opening only ever reflects the current open() call's own RPC.
|
||||||
|
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||||
|
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
expect(viewer.opening.value).toBeNull() // idle before any open()
|
||||||
|
const p = viewer.open(IMAGE_ITEM)
|
||||||
|
expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM))
|
||||||
|
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||||
|
await p
|
||||||
|
expect(viewer.opening.value).toBeNull() // back to idle the instant the fetch settles — no lingering "refreshing" state
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller', async () => {
|
||||||
|
mockedRpc.call.mockRejectedValue(new Error('Request timeout'))
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
await expect(viewer.open(IMAGE_ITEM)).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
expect(viewer.error.value).toBeTruthy()
|
||||||
|
expect(viewer.opening.value).toBeNull()
|
||||||
|
expect(viewer.lightboxIndex.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('issues exactly one RPC when open() is called twice in quick succession for the same item', async () => {
|
||||||
|
const d = deferred<{ data_base64: string; mime_type: string }>()
|
||||||
|
mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType<typeof rpcClient.call>)
|
||||||
|
const viewer = usePaidItemViewer()
|
||||||
|
|
||||||
|
const p1 = viewer.open(IMAGE_ITEM)
|
||||||
|
const p2 = viewer.open(IMAGE_ITEM)
|
||||||
|
|
||||||
|
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' })
|
||||||
|
await Promise.all([p1, p2])
|
||||||
|
|
||||||
|
expect(mockedRpc.call).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,8 +161,9 @@
|
|||||||
<div
|
<div
|
||||||
v-for="it in paidItems"
|
v-for="it in paidItems"
|
||||||
:key="it.onion + it.content_id"
|
:key="it.onion + it.content_id"
|
||||||
class="glass-card p-3 flex items-center gap-3 cursor-pointer hover:bg-white/5 transition-colors"
|
class="glass-card p-3 flex items-center gap-3 transition-colors"
|
||||||
@click="viewPaidItem(it)"
|
:class="paidItemViewer.opening.value === paidItemKey(it) ? 'cursor-default' : 'cursor-pointer hover:bg-white/5'"
|
||||||
|
@click="paidItemViewer.opening.value === paidItemKey(it) ? null : viewPaidItem(it)"
|
||||||
>
|
>
|
||||||
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
|
<span class="text-xl shrink-0">{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
@@ -173,7 +174,14 @@
|
|||||||
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
|
<span v-if="it.purchased_at"> · {{ new Date(it.purchased_at).toLocaleDateString() }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
<span
|
||||||
|
v-if="paidItemViewer.opening.value === paidItemKey(it)"
|
||||||
|
class="text-[10px] px-2 py-0.5 rounded-full bg-white/10 text-white/60 shrink-0 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span class="w-3 h-3 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></span>
|
||||||
|
Opening…
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-[10px] px-2 py-0.5 rounded-full bg-emerald-400/15 text-emerald-300 shrink-0">Paid</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,6 +406,18 @@
|
|||||||
:stream-url="cloudStore.streamUrl"
|
:stream-url="cloudStore.streamUrl"
|
||||||
@close="lightboxIndex = null"
|
@close="lightboxIndex = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Media viewer for purchased items (UIFIX-04) — synthetic items fed by
|
||||||
|
usePaidItemViewer, resolved from the already-fetched blob URL. -->
|
||||||
|
<MediaLightbox
|
||||||
|
v-if="paidItemViewer.lightboxIndex.value !== null"
|
||||||
|
:items="paidItemViewer.lightboxItems.value"
|
||||||
|
:start-index="paidItemViewer.lightboxIndex.value"
|
||||||
|
:show="paidItemViewer.lightboxIndex.value !== null"
|
||||||
|
:fetch-blob-url="paidItemViewer.resolveBlobUrl"
|
||||||
|
:stream-url="paidItemViewer.resolveBlobUrl"
|
||||||
|
@close="paidItemViewer.closeLightbox()"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -412,6 +432,7 @@ import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-clien
|
|||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
import { getFileCategory } from '../composables/useFileType'
|
import { getFileCategory } from '../composables/useFileType'
|
||||||
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
import { useAudioPlayer } from '../composables/useAudioPlayer'
|
||||||
|
import { usePaidItemViewer, paidItemKey } from '../composables/usePaidItemViewer'
|
||||||
import FileCard from '../components/cloud/FileCard.vue'
|
import FileCard from '../components/cloud/FileCard.vue'
|
||||||
import ShareModal from '../components/cloud/ShareModal.vue'
|
import ShareModal from '../components/cloud/ShareModal.vue'
|
||||||
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
import MediaLightbox from '../components/cloud/MediaLightbox.vue'
|
||||||
@@ -469,29 +490,13 @@ const paidLoading = computed(() => paidResource.entry.loadState === 'loading')
|
|||||||
function loadPaidItems() {
|
function loadPaidItems() {
|
||||||
return paidResource.refresh()
|
return paidResource.refresh()
|
||||||
}
|
}
|
||||||
|
// Fetch, decode, route-to-viewer state for a purchased item (UIFIX-04
|
||||||
|
// lightbox routing + UIFIX-06 loading/error surfacing). See the composable
|
||||||
|
// for the mime-routing rules and URL-ownership contract.
|
||||||
|
const paidItemViewer = usePaidItemViewer()
|
||||||
async function viewPaidItem(it: PaidItem) {
|
async function viewPaidItem(it: PaidItem) {
|
||||||
try {
|
await paidItemViewer.open(it)
|
||||||
const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({
|
if (paidItemViewer.error.value) loadError.value = paidItemViewer.error.value
|
||||||
method: 'content.owned-get',
|
|
||||||
params: { onion: it.onion, content_id: it.content_id },
|
|
||||||
timeout: 60000,
|
|
||||||
})
|
|
||||||
const b64 = res.data_base64 || res.data
|
|
||||||
if (!b64) 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 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')
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
|
||||||
} catch { /* viewer is best-effort; the file is also in the user's folders */ }
|
|
||||||
}
|
}
|
||||||
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
|
watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user