archy/neode-ui/src/views/web5/Web5SharedContent.vue

551 lines
25 KiB
Vue
Raw Normal View History

<template>
<!-- Shared Content -->
<div class="glass-card p-6">
<!-- Desktop: side-by-side -->
<div class="hidden md:flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
<p class="text-xs text-white/60">{{ t('web5.contentDesc') }}</p>
</div>
</div>
<div v-if="contentTab === 'mine'" class="flex items-center gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button glass-button-sm px-3 rounded-lg text-sm font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Mobile: stacked -->
<div class="md:hidden mb-4">
<div class="flex items-center gap-3 mb-2">
<div class="flex-shrink-0 w-10 h-10 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-5 h-5 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
</div>
<h2 class="text-lg font-semibold text-white">{{ t('web5.content') }}</h2>
</div>
<p class="text-xs text-white/60 mb-3">{{ t('web5.contentDesc') }}</p>
<div v-if="contentTab === 'mine'" class="grid grid-cols-2 gap-2">
<button @click="loadContentItems" :disabled="contentLoading" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center">
{{ contentLoading ? '...' : 'Refresh' }}
</button>
<button @click="showAddContentModal = true" class="glass-button min-h-[44px] rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
</div>
<!-- Browse Peer Selector -->
<div class="mb-4">
<div class="flex items-center gap-3">
<select
v-model="browsePeerOnion"
class="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white text-sm border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
>
<option value="">{{ t('web5.selectPeer') }}</option>
<option v-for="p in peers" :key="p.pubkey" :value="p.onion">
{{ p.name || p.onion || (p.pubkey || '').slice(0, 12) + '...' }}
</option>
</select>
<button
@click="browsePeerContent"
:disabled="!browsePeerOnion || browsingPeerContent"
class="glass-button glass-button-sm px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ browsingPeerContent ? t('common.loading') : t('web5.browse') }}
</button>
</div>
<p v-if="browsePeerError" class="text-xs text-red-400 mt-2">{{ browsePeerError }}</p>
</div>
<!-- Tabs: My Content | Browse Peers -->
<div class="flex gap-1 mb-4 border-b border-white/10">
<button
@click="contentTab = 'mine'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'mine' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.myContent') }}
<span v-if="contentItems.length > 0" class="ml-1.5 text-xs text-white/50">({{ contentItems.length }})</span>
</button>
<button
@click="contentTab = 'browse'"
class="px-4 py-2 text-sm font-medium rounded-t-lg transition-colors"
:class="contentTab === 'browse' ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white/80 hover:bg-white/5'"
>
{{ t('web5.browsePeers') }}
</button>
</div>
<!-- My Content tab -->
<div v-show="contentTab === 'mine'">
<div v-if="contentLoading && contentItems.length === 0" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">{{ t('common.loading') }}</p>
</div>
<div v-else-if="contentItems.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.noSharedContent') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.addFilesToShare') }}</p>
</div>
<div v-else class="space-y-3">
<div
v-for="(item, idx) in contentItems"
:key="item.id"
:class="{ 'card-stagger': showStagger }" class="p-4 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="flex items-start justify-between gap-3 mb-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ item.filename }}</p>
<p v-if="item.description" class="text-xs text-white/50 mt-0.5">{{ item.description }}</p>
<p class="text-xs text-white/40 mt-0.5">{{ item.mime_type }} &middot; {{ formatBytes(item.size_bytes) }}</p>
</div>
<button
@click="removeContentItem(item.id)"
:disabled="removingContentId === item.id"
class="p-2 rounded-lg text-white/40 hover:text-red-400 hover:bg-white/10 transition-colors shrink-0"
title="Remove"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<button
v-for="opt in accessOptions"
:key="opt.value"
@click="setContentPricing(item, opt.value)"
:disabled="updatingPricingId === item.id"
class="px-3 py-1 text-xs rounded-lg border transition-colors"
:class="getAccessType(item.access) === opt.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10 hover:text-white/70'"
>
{{ opt.label }}
</button>
</div>
<div v-if="getAccessType(item.access) === 'paid'" class="flex items-center gap-3 mt-2">
<div class="flex items-center gap-2 flex-1">
<input
:value="getItemPrice(item.access)"
@change="updateItemPrice(item, ($event.target as HTMLInputElement).value)"
type="number"
min="1"
placeholder="100"
class="w-24 px-2 py-1 text-xs rounded-lg bg-white/5 border border-white/10 text-white focus:outline-none focus:border-white/30"
/>
<span class="text-xs text-white/50">sats</span>
</div>
<p class="text-xs text-orange-400/80">Peers will pay {{ getItemPrice(item.access) || 0 }} sats to access this</p>
</div>
<p v-else-if="getAccessType(item.access) === 'free'" class="text-xs text-green-400/70 mt-1">{{ t('web5.freeAccessDesc') }}</p>
<p v-else-if="getAccessType(item.access) === 'peers_only'" class="text-xs text-blue-400/70 mt-1">{{ t('web5.peersOnlyAccessDesc') }}</p>
</div>
</div>
</div>
<!-- Browse Peers tab -->
<div v-show="contentTab === 'browse'">
<div v-if="browsingPeerContent" class="py-4 text-center">
<svg class="animate-spin h-6 w-6 text-blue-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">{{ t('web5.connectingToPeer') }}</p>
</div>
<div v-else-if="!browsePeerOnion && peerContentItems.length === 0" class="py-6 text-center">
<svg class="w-12 h-12 text-white/20 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<p class="text-white/60 text-sm mb-1">{{ t('web5.selectPeerToBrowse') }}</p>
<p class="text-white/40 text-xs">{{ t('web5.choosePeerDesc') }}</p>
</div>
<div v-else-if="peerContentItems.length === 0 && browsePeerOnion && !browsingPeerContent" class="py-6 text-center">
<p class="text-white/60 text-sm">{{ t('web5.peerNoContent') }}</p>
</div>
<div v-else class="space-y-2">
<div
v-for="(pItem, idx) in peerContentItems"
:key="pItem.id"
:class="{ 'card-stagger': showStagger }" class="flex items-center gap-4 p-3 bg-white/5 rounded-lg"
:style="{ '--stagger-index': idx }"
>
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center shrink-0">
<svg v-if="isMediaType(pItem.mime_type)" class="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg v-else class="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">{{ pItem.filename }}</p>
<p v-if="pItem.description" class="text-xs text-white/50 truncate">{{ pItem.description }}</p>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-xs text-white/40">{{ pItem.mime_type }}</span>
<span class="text-xs text-white/30">&middot;</span>
<span class="text-xs text-white/40">{{ formatBytes(pItem.size_bytes) }}</span>
<span v-if="getItemPrice(pItem.access) > 0" class="text-xs text-orange-400 ml-1">{{ getItemPrice(pItem.access) }} sats</span>
<span v-else class="text-xs text-green-400/70 ml-1">Free</span>
</div>
</div>
<button
v-if="isMediaType(pItem.mime_type)"
@click="streamPeerContent(pItem)"
class="px-3 py-1.5 text-xs rounded-lg bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
>
{{ t('web5.stream') }}
</button>
<button
v-else
@click="downloadPeerContent(pItem)"
class="px-3 py-1.5 text-xs rounded-lg bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors shrink-0"
>
{{ t('web5.download') }}
</button>
</div>
</div>
</div>
</div>
<!-- Content Streaming Player -->
<Teleport to="body">
<div v-if="streamingItem" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="closePlayer" @keydown.escape="closePlayer">
<div class="glass-card p-0 w-full max-w-2xl overflow-hidden" role="dialog" aria-modal="true">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white truncate">{{ streamingItem.filename }}</p>
<p class="text-xs text-white/50">{{ streamingItem.mime_type }}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<div v-if="streamCostSats > 0" class="flex items-center gap-1 px-2 py-1 rounded bg-orange-500/20">
<span class="text-xs text-orange-400 font-medium">{{ streamCostSats }} sats</span>
</div>
<button @click="closePlayer" class="p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors">
<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 class="p-4">
<div v-if="streamingItem.mime_type.startsWith('audio/')">
<audio ref="audioPlayerRef" :src="streamUrl" controls class="w-full" @timeupdate="onPlayerTimeUpdate" @error="onPlayerError"></audio>
</div>
<div v-else-if="streamingItem.mime_type.startsWith('video/')">
<video ref="videoPlayerRef" :src="streamUrl" controls class="w-full rounded-lg max-h-[60vh]" @timeupdate="onPlayerTimeUpdate" @error="onPlayerError"></video>
</div>
<div v-if="playerError" class="mt-3 alert-error">
<p>{{ playerError }}</p>
<p class="text-white/50 text-xs mt-1">This may be a Tor-only resource. Copy the URL to use with a Tor-enabled media player.</p>
</div>
<div class="flex items-center justify-between mt-3">
<div class="text-xs text-white/40">
{{ formatBytes(streamingItem.size_bytes) }}
<span v-if="streamProgress > 0"> &middot; {{ Math.round(streamProgress * 100) }}% streamed</span>
</div>
<button @click="copyStreamUrl" class="text-xs text-white/50 hover:text-white transition-colors">Copy URL</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Add Content Modal -->
<Teleport to="body">
<div v-if="showAddContentModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="showAddContentModal = false" @keydown.escape="showAddContentModal = false">
<div class="glass-card p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="add-content-title">
<h2 id="add-content-title" class="text-lg font-bold text-white mb-4">{{ t('web5.addContentTitle') }}</h2>
<div class="space-y-4">
<div>
<label class="text-white/60 text-sm block mb-1">Filename</label>
<input v-model="newContentFilename" type="text" placeholder="my-file.mp3" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">MIME Type</label>
<input v-model="newContentMimeType" type="text" placeholder="audio/mpeg" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-1">Description (optional)</label>
<input v-model="newContentDescription" type="text" placeholder="A short description" class="w-full input-glass" />
</div>
<div>
<label class="text-white/60 text-sm block mb-2">Access</label>
<div class="flex gap-2">
<button
v-for="opt in accessOptions"
:key="opt.value"
@click="newContentAccess = opt.value"
class="px-3 py-1.5 text-xs rounded-lg border transition-colors"
:class="newContentAccess === opt.value
? 'bg-white/15 border-white/30 text-white'
: 'bg-white/5 border-white/10 text-white/50 hover:bg-white/10'"
>{{ opt.label }}</button>
</div>
</div>
<div v-if="newContentAccess === 'paid'">
<label class="text-white/60 text-sm block mb-1">Price (sats)</label>
<input v-model.number="newContentPrice" type="number" min="1" placeholder="100" class="w-full input-glass" />
<p v-if="newContentPrice > 0" class="text-xs text-orange-400/80 mt-1">Peers will pay {{ newContentPrice }} sats to access this</p>
</div>
</div>
<div v-if="addContentError" class="mt-3 alert-error">
<p class="text-xs">{{ addContentError }}</p>
</div>
<div class="flex gap-3 mt-6">
<button @click="showAddContentModal = false; addContentError = ''" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.cancel') }}</button>
<button @click="addContentItem" :disabled="addingContent || !newContentFilename.trim()" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ addingContent ? 'Adding...' : 'Add' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import { formatBytes, isMediaType, getAccessType, getItemPrice, safeClipboardWrite } from './utils'
import type { ContentItemData, PeerContentItem, Peer } from './types'
const { t } = useI18n()
defineProps<{
showStagger: boolean
peers: Peer[]
}>()
const emit = defineEmits<{
toast: [text: string]
}>()
const contentItems = ref<ContentItemData[]>([])
const contentLoading = ref(false)
const contentTab = ref<'mine' | 'browse'>('mine')
const showAddContentModal = ref(false)
const newContentFilename = ref('')
const newContentMimeType = ref('application/octet-stream')
const newContentDescription = ref('')
const newContentAccess = ref<'free' | 'peers_only' | 'paid'>('free')
const newContentPrice = ref<number>(100)
const addingContent = ref(false)
const addContentError = ref('')
const removingContentId = ref<string | null>(null)
const updatingPricingId = ref<string | null>(null)
const accessOptions = [
{ value: 'free' as const, label: 'Free' },
{ value: 'peers_only' as const, label: 'Peers Only' },
{ value: 'paid' as const, label: 'Paid' },
]
// Browse peers
const browsePeerOnion = ref('')
const browsingPeerContent = ref(false)
const browsePeerError = ref('')
const peerContentItems = ref<PeerContentItem[]>([])
// Streaming player
const streamingItem = ref<PeerContentItem | null>(null)
const streamUrl = ref('')
const streamCostSats = ref(0)
const streamProgress = ref(0)
const playerError = ref('')
const audioPlayerRef = ref<HTMLAudioElement | null>(null)
const videoPlayerRef = ref<HTMLVideoElement | null>(null)
async function loadContentItems() {
contentLoading.value = true
try {
const res = await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })
contentItems.value = res.items || []
} catch {
contentItems.value = []
} finally {
contentLoading.value = false
}
}
async function addContentItem() {
if (addingContent.value || !newContentFilename.value.trim()) return
addingContent.value = true
addContentError.value = ''
try {
await rpcClient.call({
method: 'content.add',
params: {
filename: newContentFilename.value.trim(),
mime_type: newContentMimeType.value.trim() || 'application/octet-stream',
description: newContentDescription.value.trim(),
},
})
if (newContentAccess.value !== 'free') {
const items = (await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })).items || []
const latest = items[items.length - 1]
if (latest) {
await rpcClient.call({
method: 'content.set-pricing',
params: {
id: latest.id,
access: newContentAccess.value,
...(newContentAccess.value === 'paid' ? { price_sats: newContentPrice.value || 100 } : {}),
},
})
}
}
showAddContentModal.value = false
newContentFilename.value = ''
newContentMimeType.value = 'application/octet-stream'
newContentDescription.value = ''
newContentAccess.value = 'free'
newContentPrice.value = 100
await loadContentItems()
emit('toast', t('web5.contentAdded'))
} catch (err: unknown) {
addContentError.value = err instanceof Error ? err.message : t('web5.failedToAddContent')
} finally {
addingContent.value = false
}
}
async function removeContentItem(id: string) {
removingContentId.value = id
try {
await rpcClient.call({ method: 'content.remove', params: { id } })
contentItems.value = contentItems.value.filter(i => i.id !== id)
emit('toast', t('web5.contentRemoved'))
} catch {
emit('toast', t('web5.failedToRemoveContent'))
} finally {
removingContentId.value = null
}
}
async function setContentPricing(item: ContentItemData, access: 'free' | 'peers_only' | 'paid') {
updatingPricingId.value = item.id
try {
const params: Record<string, unknown> = { id: item.id, access }
if (access === 'paid') {
params.price_sats = getItemPrice(item.access) || 100
}
await rpcClient.call({ method: 'content.set-pricing', params })
await loadContentItems()
} catch {
emit('toast', t('web5.failedToUpdatePricing'))
} finally {
updatingPricingId.value = null
}
}
async function updateItemPrice(item: ContentItemData, value: string) {
const price = parseInt(value, 10)
if (!price || price <= 0) return
updatingPricingId.value = item.id
try {
await rpcClient.call({
method: 'content.set-pricing',
params: { id: item.id, access: 'paid', price_sats: price },
})
await loadContentItems()
} catch {
emit('toast', t('web5.failedToUpdatePrice'))
} finally {
updatingPricingId.value = null
}
}
async function browsePeerContent() {
if (!browsePeerOnion.value || browsingPeerContent.value) return
browsingPeerContent.value = true
browsePeerError.value = ''
peerContentItems.value = []
try {
const res = await rpcClient.call<{ items: PeerContentItem[] }>({
method: 'content.browse-peer',
params: { onion: browsePeerOnion.value },
})
peerContentItems.value = res.items || []
} catch (err: unknown) {
browsePeerError.value = err instanceof Error ? err.message : t('web5.failedToConnectPeer')
} finally {
browsingPeerContent.value = false
}
}
function streamPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return
streamingItem.value = item
streamUrl.value = `http://${browsePeerOnion.value}/content/${item.id}`
streamCostSats.value = getItemPrice(item.access)
streamProgress.value = 0
playerError.value = ''
}
function downloadPeerContent(item: PeerContentItem) {
if (!browsePeerOnion.value) return
const url = `http://${browsePeerOnion.value}/content/${item.id}`
emit('toast', t('web5.downloadUrlCopied'))
safeClipboardWrite(url)
}
function closePlayer() {
if (audioPlayerRef.value) {
audioPlayerRef.value.pause()
audioPlayerRef.value.src = ''
}
if (videoPlayerRef.value) {
videoPlayerRef.value.pause()
videoPlayerRef.value.src = ''
}
streamingItem.value = null
streamUrl.value = ''
streamProgress.value = 0
playerError.value = ''
}
function onPlayerTimeUpdate() {
const player = audioPlayerRef.value || videoPlayerRef.value
if (player && player.duration > 0) {
streamProgress.value = player.currentTime / player.duration
}
}
function onPlayerError() {
playerError.value = t('web5.playerError')
}
function copyStreamUrl() {
if (streamUrl.value) {
safeClipboardWrite(streamUrl.value)
emit('toast', t('web5.streamUrlCopied'))
}
}
defineExpose({ loadContentItems })
</script>