feat: add uniapp match live stream switching
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<u-popup v-model="showPopup" mode="bottom" safe-area-inset-bottom border-radius="24" closeable @close="handleClose">
|
||||
<view class="match-live-popup">
|
||||
<view class="match-live-popup__header">
|
||||
<text class="match-live-popup__title">{{ lineTitle }}</text>
|
||||
<text class="match-live-popup__desc">{{ lineMetaText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="match-live-popup__section">
|
||||
<text class="match-live-popup__section-title">直播线路</text>
|
||||
<view v-if="streamOptions.length" class="match-live-popup__options">
|
||||
<view
|
||||
v-for="item in streamOptions"
|
||||
:key="item.key"
|
||||
class="match-live-popup__option"
|
||||
:class="{ 'match-live-popup__option--active': item.key === selectedStreamKey }"
|
||||
@tap="selectOption(item)"
|
||||
>
|
||||
<view class="match-live-popup__option-main">
|
||||
<text class="match-live-popup__option-title">{{ item.label }}</text>
|
||||
<text class="match-live-popup__option-url">{{ item.url }}</text>
|
||||
</view>
|
||||
<text v-if="item.key === selectedStreamKey" class="match-live-popup__option-tag">当前</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="match-live-popup__empty">
|
||||
<text>暂无可用直播线路</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="match-live-popup__actions">
|
||||
<u-button type="primary" shape="circle" @click="openSelected">打开当前线路</u-button>
|
||||
<u-button shape="circle" plain @click="openSource">打开源站</u-button>
|
||||
</view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { MatchLiveLineItem, MatchLiveStreamOption } from '@/api/match'
|
||||
|
||||
type PopupStreamOption = MatchLiveStreamOption & {
|
||||
key: string
|
||||
label: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
line: MatchLiveLineItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:show', 'close'])
|
||||
|
||||
const showPopup = computed({
|
||||
get: () => props.show,
|
||||
set: (value: boolean) => emit('update:show', value),
|
||||
})
|
||||
|
||||
const selectedStreamKey = ref('')
|
||||
|
||||
const normalizeText = (value: unknown) => String(value || '').trim()
|
||||
|
||||
const normalizeUrl = (value: unknown) => {
|
||||
const text = normalizeText(value)
|
||||
return text
|
||||
}
|
||||
|
||||
const formatMetaTime = (value: unknown) => {
|
||||
const text = normalizeText(value)
|
||||
if (!text) return ''
|
||||
const hhmmss = text.match(/(\d{2}:\d{2}:\d{2})$/)
|
||||
if (hhmmss) return hhmmss[1]
|
||||
const hhmm = text.match(/(\d{2}:\d{2})$/)
|
||||
if (hhmm) return hhmm[1]
|
||||
if (/^\d{10,13}$/.test(text)) {
|
||||
const raw = text.length === 13 ? Number(text) : Number(text) * 1000
|
||||
const date = new Date(raw)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hour}:${minute}`
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const lineTitle = computed(() => {
|
||||
const line = props.line || {}
|
||||
return (
|
||||
normalizeText(line.title) ||
|
||||
normalizeText(line.name) ||
|
||||
normalizeText(line.label) ||
|
||||
normalizeText(line.live_tag) ||
|
||||
normalizeText(line.source_name) ||
|
||||
'直播线路'
|
||||
)
|
||||
})
|
||||
|
||||
const lineMetaText = computed(() => {
|
||||
const line = props.line || {}
|
||||
const parts = [
|
||||
normalizeText(line.status_text) || normalizeText(line.status_desc) || normalizeText(line.status),
|
||||
formatMetaTime(line.updated_at || line.update_time),
|
||||
].filter(Boolean)
|
||||
return parts.length ? parts.join(' · ') : '请选择可用直播线路后打开'
|
||||
})
|
||||
|
||||
const sourceUrl = computed(() => normalizeUrl(props.line?.source_url))
|
||||
|
||||
const buildStreamOption = (option: MatchLiveStreamOption | undefined, index: number) => {
|
||||
if (!option) return null
|
||||
const url =
|
||||
normalizeUrl(option.play_url_m3u8) ||
|
||||
normalizeUrl(option.play_url) ||
|
||||
normalizeUrl(option.url) ||
|
||||
normalizeUrl(option.default_play_url) ||
|
||||
normalizeUrl(option.live_url)
|
||||
if (!url) return null
|
||||
return {
|
||||
...option,
|
||||
key: `${index}-${url}`,
|
||||
label:
|
||||
normalizeText(option.title) ||
|
||||
normalizeText(option.name) ||
|
||||
normalizeText(option.label) ||
|
||||
normalizeText(option.source_name) ||
|
||||
normalizeText(option.quality) ||
|
||||
`线路${index + 1}`,
|
||||
url,
|
||||
} satisfies PopupStreamOption
|
||||
}
|
||||
|
||||
const streamOptions = computed(() => {
|
||||
const line = props.line || {}
|
||||
const options: PopupStreamOption[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const pushOption = (option: MatchLiveStreamOption | undefined, index: number) => {
|
||||
const built = buildStreamOption(option, index)
|
||||
if (!built || seen.has(built.url)) return
|
||||
seen.add(built.url)
|
||||
options.push(built)
|
||||
}
|
||||
|
||||
if (Array.isArray(line.stream_options)) {
|
||||
line.stream_options.forEach((item, index) => pushOption(item, index))
|
||||
}
|
||||
|
||||
const defaultPlayUrl = normalizeUrl(line.default_play_url)
|
||||
if (defaultPlayUrl && !seen.has(defaultPlayUrl)) {
|
||||
pushOption({ label: '默认线路', url: defaultPlayUrl }, options.length)
|
||||
}
|
||||
|
||||
const playUrlM3u8 = normalizeUrl(line.play_url_m3u8)
|
||||
if (playUrlM3u8 && !seen.has(playUrlM3u8)) {
|
||||
pushOption({ label: 'M3U8 线路', play_url_m3u8: playUrlM3u8 }, options.length)
|
||||
}
|
||||
|
||||
const playUrl = normalizeUrl(line.play_url)
|
||||
if (playUrl && !seen.has(playUrl)) {
|
||||
pushOption({ label: '备用线路', play_url: playUrl }, options.length)
|
||||
}
|
||||
|
||||
const liveUrl = normalizeUrl(line.live_url)
|
||||
if (liveUrl && !seen.has(liveUrl)) {
|
||||
pushOption({ label: '直播地址', live_url: liveUrl }, options.length)
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const selectedStream = computed(() => {
|
||||
return streamOptions.value.find((item) => item.key === selectedStreamKey.value) || streamOptions.value[0] || null
|
||||
})
|
||||
|
||||
const resetSelected = () => {
|
||||
const defaultPlayUrl = normalizeUrl(props.line?.default_play_url)
|
||||
if (defaultPlayUrl) {
|
||||
const matched = streamOptions.value.find((item) => item.url === defaultPlayUrl)
|
||||
if (matched) {
|
||||
selectedStreamKey.value = matched.key
|
||||
return
|
||||
}
|
||||
}
|
||||
selectedStreamKey.value = streamOptions.value[0]?.key || ''
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.line, props.show],
|
||||
() => {
|
||||
resetSelected()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
const navigateToWebview = (url: string) => {
|
||||
uni.navigateTo({ url: `/pages/webview/webview?url=${encodeURIComponent(url)}` })
|
||||
}
|
||||
|
||||
const selectOption = (item: PopupStreamOption) => {
|
||||
selectedStreamKey.value = item.key
|
||||
}
|
||||
|
||||
const openSelected = () => {
|
||||
const url = selectedStream.value?.url || ''
|
||||
if (!url) {
|
||||
uni.showToast({ title: '暂无可用直播链接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
navigateToWebview(url)
|
||||
}
|
||||
|
||||
const openSource = () => {
|
||||
if (!sourceUrl.value) {
|
||||
uni.showToast({ title: '暂无源站链接', icon: 'none' })
|
||||
return
|
||||
}
|
||||
navigateToWebview(sourceUrl.value)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.match-live-popup {
|
||||
padding: 40rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 24rpx;
|
||||
color: #6b7280;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
display: block;
|
||||
margin-bottom: 18rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
&__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 22rpx 24rpx;
|
||||
border: 2rpx solid #e5e7eb;
|
||||
border-radius: 20rpx;
|
||||
background: #f9fafb;
|
||||
|
||||
&--active {
|
||||
border-color: #185dff;
|
||||
background: rgba(24, 93, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__option-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__option-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
&__option-url {
|
||||
font-size: 22rpx;
|
||||
color: #6b7280;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&__option-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #185dff;
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 40rpx 0;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user