feat: move live line switcher into player

This commit is contained in:
hajimi
2026-08-03 03:42:37 +08:00
parent 1e589357fe
commit 1737ee517c
+115 -205
View File
@@ -13,61 +13,53 @@
<scroll-view v-else scroll-y class="detail-content" :style="{ height: contentHeight + 'px' }">
<view v-if="liveList.length" class="live-entrance">
<view class="live-entrance__player-card">
<view v-if="activeLivePlayUrl" class="live-entrance__player-wrap">
<video id="match-live-player" :key="activeLivePlayerKey" class="live-entrance__player"
:src="activeLivePlayUrl" :poster="match.home_icon || match.away_icon || ''" :autoplay="true"
:controls="true" :show-play-btn="true" :show-center-play-btn="true" :show-progress="false"
:show-fullscreen-btn="true" :page-gesture="true" object-fit="contain"
@play="handleLivePlayerPlay" @loadedmetadata="handleLivePlayerLoaded"
@waiting="handleLivePlayerWaiting" @error="handleLivePlayerError" />
<view v-if="match.status === 1" class="live-entrance__live-status">
<text>直播中</text>
<text class="live-entrance__live-minute">{{ match.current_minute || '进行中' }}</text>
</view>
<view v-if="activeLivePlayUrl" class="live-entrance__fullscreen" @tap.stop="openLiveFullscreen">
<text class="live-entrance__fullscreen-icon"></text>
<text>全屏</text>
</view>
<view v-if="livePlayerLoading" class="live-entrance__player-loading">
<u-loading mode="circle" color="#ffffff" />
<text class="live-entrance__player-loading-text">正在连接直播</text>
</view>
</view>
<view v-else class="live-entrance__player-empty">
<text class="live-entrance__player-empty-title">当前线路暂无可用直播放流</text>
<text class="live-entrance__player-empty-desc">可切换其他线路或打开当前线路源站继续观看</text>
<u-button v-if="activeLiveSourceUrl" size="mini" shape="circle" plain
class="live-entrance__empty-action" @click.stop="openActiveLiveSource">
打开当前线路源站
</u-button>
</view>
<view class="live-entrance__player-header">
<view class="live-entrance__player-title-wrap">
<text class="live-entrance__eyebrow">{{ resolvedLiveList.length > 1 ? `直播间 · ${resolvedLiveList.length}` : '直播间' }}</text>
<text class="live-entrance__player-title">{{ activeLiveLineTitle }}</text>
<text v-if="activeLiveMetaText" class="live-entrance__player-meta">{{ activeLiveMetaText }}</text>
</view>
<view v-if="activeLiveSourceUrl" class="live-entrance__source-action" @tap.stop="openActiveLiveSource">
<text>源站</text>
<text></text>
</view>
</view>
<text v-if="activeLiveError" class="live-entrance__player-error">{{ activeLiveError }}</text>
<scroll-view v-if="resolvedLiveList.length > 1" scroll-x class="live-entrance__room-tabs"
show-scrollbar="false">
<view class="live-entrance__room-tabs-inner">
<view v-for="(item, index) in resolvedLiveList" :key="item.lineKey"
class="live-entrance__room-tab"
:class="{ 'live-entrance__room-tab--active': item.lineKey === activeLiveLineKey }"
@tap="selectLiveLine(item)">
<text class="live-entrance__room-index">{{ index + 1 }}</text>
<text class="live-entrance__room-name">{{ item.anchor_name || item.displayTitle }}</text>
<view class="live-entrance__stage">
<view v-if="activeLivePlayUrl" class="live-entrance__player-wrap">
<video id="match-live-player" :key="activeLivePlayerKey" class="live-entrance__player"
:src="activeLivePlayUrl" :poster="match.home_icon || match.away_icon || ''" :autoplay="true"
:controls="true" :show-play-btn="true" :show-center-play-btn="true" :show-progress="false"
:show-fullscreen-btn="true" :page-gesture="true" object-fit="contain"
@play="handleLivePlayerPlay" @loadedmetadata="handleLivePlayerLoaded"
@waiting="handleLivePlayerWaiting" @error="handleLivePlayerError" />
<view v-if="match.status === 1" class="live-entrance__live-status">
<text>直播中</text>
<text class="live-entrance__live-minute">{{ match.current_minute || '进行中' }}</text>
</view>
<view class="live-entrance__fullscreen" @tap.stop="openLiveFullscreen">
<text class="live-entrance__fullscreen-icon"></text>
<text>全屏</text>
</view>
<view v-if="livePlayerLoading" class="live-entrance__player-loading">
<u-loading mode="circle" color="#ffffff" />
<text class="live-entrance__player-loading-text">正在连接直播</text>
</view>
</view>
</scroll-view>
<view v-else class="live-entrance__player-empty">
<text class="live-entrance__player-empty-title">当前线路暂无可用直播放流</text>
<text class="live-entrance__player-empty-desc">可切换其他线路或打开当前线路源站继续观看</text>
<u-button v-if="activeLiveSourceUrl" size="mini" shape="circle" plain
class="live-entrance__empty-action" @click.stop="openActiveLiveSource">
打开当前线路源站
</u-button>
</view>
<view v-if="resolvedLiveList.length > 1" class="live-entrance__line-switcher" @tap.stop>
<view class="live-entrance__line-trigger" @tap.stop="toggleLiveLineMenu">
<text>切换线路</text>
<text class="live-entrance__line-trigger-arrow">{{ showLiveLineMenu ? '▴' : '▾' }}</text>
</view>
<view v-if="showLiveLineMenu" class="live-entrance__line-menu">
<view v-for="(item, index) in resolvedLiveList" :key="item.lineKey"
class="live-entrance__line-option"
:class="{ 'live-entrance__line-option--active': item.lineKey === activeLiveLineKey }"
@tap.stop="selectLiveLine(item)">
<text class="live-entrance__line-option-index">{{ index + 1 }}</text>
<text class="live-entrance__line-option-name">{{ item.displayTitle }}</text>
<text v-if="item.lineKey === activeLiveLineKey" class="live-entrance__line-option-check"></text>
</view>
</view>
</view>
</view>
<text v-if="activeLiveError" class="live-entrance__player-error">{{ activeLiveError }}</text>
</view>
</view>
@@ -482,7 +474,6 @@ import { AI_DAILY_FREE_TEXT, getAiFreeRemain } from '@/config/ai'
type MatchDetailLiveLine = MatchLiveLineItem & {
lineKey: string
displayTitle: string
metaText: string
playUrl: string
sourceUrl: string
hasDirectPlay: boolean
@@ -507,6 +498,7 @@ const liveTextList = ref<any[]>([])
const activeLiveLineKey = ref('')
const activeLiveError = ref('')
const livePlayerLoading = ref(false)
const showLiveLineMenu = ref(false)
let liveTextTimer: ReturnType<typeof setInterval> | null = null
const matchDetailTitle = computed(() => {
@@ -553,16 +545,6 @@ const awaySubs = computed(() => lineupList.value.filter((p: any) => p.team_side
const normalizeLiveText = (value: unknown) => String(value || '').trim()
const getLiveFetchStatusText = (value: unknown) => {
const text = normalizeLiveText(value)
if (!text) return ''
const normalized = text.toLowerCase()
if (normalized === 'pending' || normalized === '0') return '待抓取'
if (normalized === 'success' || normalized === '1') return '已抓取'
if (normalized === 'failed' || normalized === 'error' || normalized === '2') return '抓取失败'
return text
}
const getLiveLineTitle = (item: MatchLiveLineItem) => {
return (
normalizeLiveText(item?.title) ||
@@ -574,28 +556,6 @@ const getLiveLineTitle = (item: MatchLiveLineItem) => {
)
}
const formatLiveLineUpdate = (value: unknown) => {
const text = normalizeLiveText(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
return formatLiveTime(raw / 1000)
}
return text
}
const getLiveLineMeta = (item: MatchLiveLineItem) => {
const parts = [
getLiveFetchStatusText(item?.fetch_status),
formatLiveLineUpdate(item?.last_fetch_at || item?.update_time),
].filter(Boolean)
return parts.length ? parts.join(' · ') : '点击选择直播线路'
}
const getLiveLineKey = (item: MatchLiveLineItem, index: number) => {
return normalizeLiveText(item?.id) || `${getLiveLineTitle(item)}-${index}`
}
@@ -656,7 +616,6 @@ const resolvedLiveList = computed<MatchDetailLiveLine[]>(() => {
...item,
lineKey: getLiveLineKey(item, index),
displayTitle: getLiveLineTitle(item),
metaText: getLiveLineMeta(item),
playUrl,
sourceUrl: resolveLiveSourceUrl(item),
hasDirectPlay: !!playUrl,
@@ -674,8 +633,6 @@ const activeLiveLine = computed(() => {
const activeLivePlayUrl = computed(() => activeLiveLine.value?.playUrl || '')
const activeLiveSourceUrl = computed(() => activeLiveLine.value?.sourceUrl || thirdPartyLiveUrl.value)
const activeLiveLineTitle = computed(() => activeLiveLine.value?.displayTitle || '直播线路')
const activeLiveMetaText = computed(() => activeLiveLine.value?.metaText || '')
const activeLivePlayerKey = computed(() => `${activeLiveLine.value?.lineKey || 'live'}-${activeLivePlayUrl.value}`)
const triggerLiveAutoPlay = () => {
@@ -699,6 +656,7 @@ watch(
activeLiveLineKey.value = ''
activeLiveError.value = ''
livePlayerLoading.value = false
showLiveLineMenu.value = false
return
}
if (list.some((item) => item.lineKey === activeLiveLineKey.value)) return
@@ -852,11 +810,16 @@ const selectLiveLine = (item: MatchDetailLiveLine) => {
activeLiveLineKey.value = item.lineKey
activeLiveError.value = ''
livePlayerLoading.value = !!item.playUrl
showLiveLineMenu.value = false
if (item.playUrl) {
triggerLiveAutoPlay()
}
}
const toggleLiveLineMenu = () => {
showLiveLineMenu.value = !showLiveLineMenu.value
}
const openActiveLiveSource = () => {
navigateToLiveWebview(activeLiveSourceUrl.value)
}
@@ -1830,41 +1793,6 @@ onUnload(() => {
gap: 18rpx;
}
&__player-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
&__player-title-wrap {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
flex: 1;
}
&__player-title {
font-size: 30rpx;
font-weight: 700;
color: #0f172a;
}
&__player-meta {
font-size: 22rpx;
color: #64748b;
line-height: 1.5;
}
&__player-actions {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
flex-shrink: 0;
}
&__player-wrap {
position: relative;
border-radius: 24rpx;
@@ -2085,6 +2013,11 @@ onUnload(() => {
box-shadow: none;
}
&__stage {
position: relative;
min-height: 430rpx;
}
&__player-wrap {
border-radius: 0;
height: 430rpx;
@@ -2143,109 +2076,86 @@ onUnload(() => {
line-height: 1;
}
&__player-header {
align-items: center;
padding: 20rpx 28rpx 22rpx;
background: #fff;
&__line-switcher {
position: absolute;
bottom: 22rpx;
left: 24rpx;
z-index: 4;
}
&__player-title-wrap {
gap: 4rpx;
}
&__eyebrow {
font-size: 20rpx;
color: #8c95a6;
line-height: 1.2;
}
&__player-title {
font-size: 25rpx;
font-weight: 700;
color: #141a27;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__player-meta {
font-size: 19rpx;
line-height: 1.3;
color: #8c95a6;
}
&__source-action {
&__line-trigger {
display: flex;
align-items: center;
gap: 4rpx;
padding: 10rpx 14rpx;
border: 1rpx solid #d7e2f7;
border-radius: 999rpx;
color: #2376f5;
font-size: 20rpx;
gap: 12rpx;
min-height: 62rpx;
padding: 0 18rpx;
border-radius: 12rpx;
background: rgba(3, 9, 17, 0.74);
color: #fff;
font-size: 24rpx;
font-weight: 600;
}
&__line-trigger-arrow {
font-size: 20rpx;
line-height: 1;
flex-shrink: 0;
}
&__room-tabs {
width: 100%;
white-space: nowrap;
background: #fff;
border-top: 1rpx solid #eff2f7;
}
&__room-tabs-inner {
display: inline-flex;
min-width: 100%;
gap: 14rpx;
padding: 16rpx 28rpx;
&__line-menu {
position: absolute;
bottom: 78rpx;
left: 0;
width: 360rpx;
max-height: 292rpx;
padding: 8rpx;
overflow-y: auto;
box-sizing: border-box;
border: 1rpx solid rgba(255, 255, 255, 0.22);
border-radius: 12rpx;
background: rgba(3, 9, 17, 0.9);
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.3);
}
&__room-tab {
display: inline-flex;
&__line-option {
display: flex;
align-items: center;
max-width: 240rpx;
gap: 8rpx;
padding: 12rpx 16rpx;
border: 1rpx solid #e5eaf2;
border-radius: 999rpx;
background: #f7f9fc;
color: #657086;
flex: 0 0 auto;
gap: 12rpx;
min-height: 60rpx;
padding: 0 12rpx;
border-radius: 8rpx;
color: rgba(255, 255, 255, 0.82);
&--active {
border-color: #1468f5;
background: #1468f5;
background: rgba(20, 104, 245, 0.28);
color: #fff;
}
}
&__room-index {
min-width: 28rpx;
height: 28rpx;
border-radius: 50%;
background: rgba(20, 104, 245, 0.12);
color: #1468f5;
font-size: 18rpx;
font-weight: 700;
line-height: 28rpx;
text-align: center;
}
&__room-tab--active &__room-index {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
&__room-name {
overflow: hidden;
&__line-option-index {
width: 32rpx;
color: rgba(255, 255, 255, 0.6);
font-size: 20rpx;
font-weight: 700;
text-align: center;
flex-shrink: 0;
}
&__line-option-name {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 22rpx;
font-weight: 600;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
&__line-option-check {
color: #7db3ff;
font-size: 24rpx;
font-weight: 700;
flex-shrink: 0;
}
&__player-empty {