mirror of
https://github.com/the1812/Bilibili-Evolved.git
synced 2025-11-04 21:22:45 +08:00
359 lines
9.3 KiB
Vue
359 lines
9.3 KiB
Vue
<template>
|
|
<div class="be-live-list">
|
|
<div class="be-live-list-header">
|
|
<div class="be-live-list-title">
|
|
正在直播
|
|
<div class="be-live-list-count">({{ items.length }})</div>
|
|
</div>
|
|
<div class="be-live-list-actions">
|
|
<div v-if="loaded" class="be-live-list-refresh" title="刷新" @click="fetchLiveList">
|
|
<VIcon icon="mdi-refresh" :size="16" />
|
|
</div>
|
|
<a
|
|
class="be-live-list-more"
|
|
target="_blank"
|
|
href="https://link.bilibili.com/p/center/index#/user-center/follow/1"
|
|
>
|
|
更多
|
|
<VIcon icon="right-arrow" :size="14" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<div class="be-live-list-search">
|
|
<VIcon icon="search" :size="18" />
|
|
<TextBox v-model="keyword" placeholder="搜索" />
|
|
</div>
|
|
<div class="be-live-list-content">
|
|
<VLoading v-if="!loaded" />
|
|
<VEmpty v-if="loaded && filteredItems.length === 0" />
|
|
<a
|
|
v-for="item of filteredItems"
|
|
:key="item.roomid"
|
|
class="be-live-list-item"
|
|
target="_blank"
|
|
:href="item.link"
|
|
>
|
|
<div class="be-live-list-item-avatar">
|
|
<DpiImage :src="item.face" :alt="item.title" :size="36" />
|
|
</div>
|
|
<div class="be-live-list-item-info">
|
|
<div class="be-live-list-item-title" :title="item.title">
|
|
{{ decodeTitle(item.title) }}
|
|
</div>
|
|
<div class="be-live-list-item-user" :title="item.uname">
|
|
{{ item.uname }}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
import { VIcon, TextBox, DpiImage, VEmpty, VLoading } from '@/ui'
|
|
import { bilibiliApi, getJsonWithCredentials, getPages, responsiveGetPages } from '@/core/ajax'
|
|
import { FollowingListID, FollowingUserInfo, LiveInfo, RawFollowingListItem } from './types'
|
|
import { getUID } from '@/core/utils'
|
|
import { ExtendFeedsLiveOptions, UnselectedListID } from './options'
|
|
import {
|
|
addComponentListener,
|
|
getComponentSettings,
|
|
removeComponentListener,
|
|
} from '@/core/settings'
|
|
|
|
const decodeTitle = (title: string) => {
|
|
const textArea = document.createElement('textarea')
|
|
textArea.innerHTML = title
|
|
return textArea.value
|
|
}
|
|
|
|
const FollowingListPageSize = 500
|
|
const { options } = getComponentSettings<ExtendFeedsLiveOptions>('extendFeedsLive')
|
|
const items = ref<LiveInfo[]>([])
|
|
const pinnedUsers = ref<FollowingUserInfo[]>([])
|
|
const hiddenUsers = ref<FollowingUserInfo[]>([])
|
|
const loaded = ref(false)
|
|
const keyword = ref('')
|
|
|
|
const filteredItems = computed(() => {
|
|
if (keyword.value) {
|
|
return items.value.filter(it => {
|
|
const matchField = (field: keyof LiveInfo) => {
|
|
return it[field].toString().toLowerCase().includes(keyword.value.toLowerCase())
|
|
}
|
|
|
|
return matchField('title') || matchField('uname') || matchField('roomid') || matchField('uid')
|
|
})
|
|
}
|
|
return items.value
|
|
})
|
|
|
|
const fetchRecommendLiveInfos = async (): Promise<LiveInfo[]> => {
|
|
// 动态 portal 接口会获取推荐的 top30 直播。同时这个接口不会忽略悄悄关注的 up 的直播。
|
|
const portalList = await getJsonWithCredentials(
|
|
'https://api.bilibili.com/x/polymer/web-dynamic/v1/portal',
|
|
)
|
|
const recommendLiveItems =
|
|
portalList.data.live_users?.items?.map(item => {
|
|
const { jump_url, room_id, face, title, uname, mid } = item
|
|
return {
|
|
cover: face,
|
|
face,
|
|
uname,
|
|
title,
|
|
roomid: room_id,
|
|
pic: '', // portal 接口没有
|
|
online: 0, // portal 接口没有
|
|
uid: mid,
|
|
link: jump_url,
|
|
}
|
|
}) ?? []
|
|
return recommendLiveItems
|
|
}
|
|
|
|
const mergeRecommendLiveInfos = (liveInfos: LiveInfo[], recommendLiveInfos: LiveInfo[]) => {
|
|
// recommendItems 里的 pic 和 online 为默认值,但是 ui 也没用到。
|
|
// 所以方便起见,直接拼接没出现在 recommendItems 里的 item
|
|
const recommendRoomIds = recommendLiveInfos.map(item => item.roomid)
|
|
const feedConcatItems = liveInfos.filter(item => !recommendRoomIds.includes(item.roomid))
|
|
return lodash.concat(recommendLiveInfos, feedConcatItems)
|
|
}
|
|
|
|
const sortByUsers = (users: FollowingUserInfo[], descending = false) => {
|
|
return (a: LiveInfo, b: LiveInfo) => {
|
|
const aPinnedIndex = users.findIndex(it => it.mid === a.uid)
|
|
const bPinnedIndex = users.findIndex(it => it.mid === b.uid)
|
|
if (bPinnedIndex === -1 && aPinnedIndex === -1) {
|
|
return 0
|
|
}
|
|
if (aPinnedIndex === -1) {
|
|
return descending ? -1 : 1
|
|
}
|
|
if (bPinnedIndex === -1) {
|
|
return descending ? 1 : -1
|
|
}
|
|
return aPinnedIndex - bPinnedIndex
|
|
}
|
|
}
|
|
|
|
const fetchFollowingLists = async () => {
|
|
const fetchFollowingList = async (id: FollowingListID) => {
|
|
if (id === UnselectedListID || id === undefined) {
|
|
return []
|
|
}
|
|
const list = await bilibiliApi<RawFollowingListItem[]>(
|
|
getJsonWithCredentials('https://api.bilibili.com/x/relation/tags'),
|
|
)
|
|
if (!list.some(it => it.tagid === id)) {
|
|
return []
|
|
}
|
|
const response = await getPages<FollowingUserInfo>({
|
|
api: page =>
|
|
getJsonWithCredentials(
|
|
`https://api.bilibili.com/x/relation/tag?tagid=${id}&pn=${page}&ps=${FollowingListPageSize}&mid=${getUID()}`,
|
|
),
|
|
getList: json => lodash.get(json, 'data', []),
|
|
getTotal: () => list.find(it => it.tagid === id)?.count ?? 0,
|
|
})
|
|
return response
|
|
}
|
|
|
|
await Promise.all([
|
|
fetchFollowingList(options.pinnedListID)
|
|
.then(users => {
|
|
pinnedUsers.value = users
|
|
})
|
|
.catch(() => {
|
|
pinnedUsers.value = []
|
|
}),
|
|
fetchFollowingList(options.hiddenListID)
|
|
.then(users => {
|
|
hiddenUsers.value = users
|
|
})
|
|
.catch(() => {
|
|
hiddenUsers.value = []
|
|
}),
|
|
])
|
|
}
|
|
|
|
const fetchLiveList = async () => {
|
|
try {
|
|
items.value = []
|
|
loaded.value = false
|
|
const [, promise] = responsiveGetPages<LiveInfo>({
|
|
api: page =>
|
|
getJsonWithCredentials(
|
|
`https://api.live.bilibili.com/xlive/web-ucenter/v1/xfetter/GetWebList?page=${page}`,
|
|
),
|
|
getList: json =>
|
|
lodash.get(json, 'data.list', []).map(item => {
|
|
const { face, uname, title, room_id, cover_from_user, online, uid, link } = item
|
|
return {
|
|
cover: face,
|
|
face,
|
|
uname,
|
|
title,
|
|
roomid: room_id,
|
|
pic: cover_from_user,
|
|
online,
|
|
uid,
|
|
link,
|
|
}
|
|
}),
|
|
getTotal: json => lodash.get(json, 'data.count', 0),
|
|
})
|
|
|
|
const [allItems, recommendItems] = await Promise.all([promise, fetchRecommendLiveInfos()])
|
|
const sortedItems = mergeRecommendLiveInfos(allItems, recommendItems)
|
|
.sort(sortByUsers(pinnedUsers.value, false))
|
|
.filter(item => !hiddenUsers.value.some(user => user.mid === item.uid))
|
|
items.value = sortedItems
|
|
} finally {
|
|
loaded.value = true
|
|
}
|
|
}
|
|
|
|
const initializeLiveList = async () => {
|
|
await fetchFollowingLists()
|
|
await fetchLiveList()
|
|
}
|
|
addComponentListener('extendFeedsLive.pinnedListID', initializeLiveList)
|
|
addComponentListener('extendFeedsLive.hiddenListID', initializeLiveList)
|
|
onMounted(() => {
|
|
initializeLiveList()
|
|
})
|
|
onBeforeUnmount(() => {
|
|
removeComponentListener('extendFeedsLive.pinnedListID', initializeLiveList)
|
|
removeComponentListener('extendFeedsLive.hiddenListID', initializeLiveList)
|
|
})
|
|
</script>
|
|
<style lang="scss">
|
|
@import 'common';
|
|
|
|
.be-live-list {
|
|
@include v-stretch(12px);
|
|
padding: 12px 16px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
color: #000;
|
|
background-color: #fff;
|
|
|
|
body.dark & {
|
|
color: var(--be-text-title-color, #eee);
|
|
background-color: var(--be-card-background-color, #444);
|
|
}
|
|
|
|
&-header {
|
|
@include h-center();
|
|
justify-content: space-between;
|
|
}
|
|
|
|
&-title {
|
|
@include h-center(4px);
|
|
font-size: 14px;
|
|
}
|
|
|
|
&-count {
|
|
opacity: 0.5;
|
|
font-size: 12px;
|
|
}
|
|
|
|
&-actions {
|
|
@include h-center(4px);
|
|
}
|
|
|
|
&-refresh {
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
|
|
.be-icon {
|
|
opacity: 0.5;
|
|
transition: 0.35s ease-out;
|
|
}
|
|
|
|
&:hover .be-icon {
|
|
transform: rotate(360deg);
|
|
opacity: 1;
|
|
color: var(--theme-color);
|
|
}
|
|
}
|
|
|
|
&-more {
|
|
opacity: 0.5;
|
|
padding: 2px;
|
|
cursor: pointer;
|
|
@include h-center();
|
|
|
|
&:hover {
|
|
opacity: 1;
|
|
color: var(--theme-color);
|
|
}
|
|
}
|
|
|
|
&-search {
|
|
@include h-center(8px);
|
|
|
|
.be-icon {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.be-textbox {
|
|
flex-grow: 1;
|
|
}
|
|
}
|
|
|
|
&-content {
|
|
@include no-scrollbar();
|
|
@include v-stretch(8px);
|
|
// 默认状态
|
|
max-height: calc(100vh - 358px);
|
|
|
|
// 开启过滤器
|
|
body.enable-feeds-filter & {
|
|
max-height: calc(100vh - 414px);
|
|
}
|
|
|
|
// 禁掉 profile 后
|
|
body.feeds-filter-side-block-profile & {
|
|
max-height: calc(100vh - 218px);
|
|
}
|
|
}
|
|
|
|
&-item {
|
|
@include h-center(8px);
|
|
cursor: pointer;
|
|
|
|
&-avatar {
|
|
overflow: hidden;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
border: 1px solid var(--be-avatar-border-color, #8884);
|
|
}
|
|
|
|
&-info {
|
|
@include v-stretch(2px);
|
|
flex-grow: 1;
|
|
width: 0;
|
|
}
|
|
|
|
&-title,
|
|
&-user {
|
|
@include single-line();
|
|
}
|
|
|
|
&-title {
|
|
font-size: 14px;
|
|
transition: 0.2s ease-out;
|
|
}
|
|
|
|
&-user {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
&:hover &-title {
|
|
color: var(--theme-color);
|
|
}
|
|
}
|
|
}
|
|
</style>
|