Merge branch 'preview-fixes'

This commit is contained in:
the1812 2025-07-22 23:49:59 +08:00
commit 4b3a1a3870
50 changed files with 2236 additions and 255 deletions

View File

@ -99,6 +99,7 @@
"superchat",
"Swiper",
"tabindex",
"tagid",
"Tampermonkey",
"Touhou",
"truetype",

View File

@ -1,6 +1,69 @@
<!-- spell-checker: disable -->
# 更新日志
## v2.10.3-preview
包含 [v2.10.3](https://github.com/the1812/Bilibili-Evolved/releases/tag/v2.10.3) 的所有更新内容.
- 新增 `深色模式` 组件, 用于跟随 b 站官方的内测深色模式. (#5272)
> 脚本自身的元素配色也会逐步适配官方的配色, 目前仅适配设置面板、自定义顶栏和动态首页中的元素
- `插件 - 下载视频 - WASM 混流输出` 修正下载限制为 2GB, 新增下载速度和剩余时间显示. (#5102, PR #5233 by [WakelessSloth56](https://github.com/WakelessSloth56))
- `弹幕空降` 支持识别中文数字, 修复对特殊弹幕未触发的问题. (PR #5265 by [fixfeat](https://github.com/fixfeat))
- 新增 `视频预览放大` 组件. (PR #5281 by [wsgh0202](https://github.com/wsgh0202))
> 放大鼠标悬停于视频卡片时播放的 5 分钟预览
>
> - 点击放大镜图标放大预览
> - 点击缩小图标或者预览框外空白处关闭预览
- 修复 `隐藏首页轮播图` 失效, 并增加了一些局部元素控制功能. (PR #5287 [wsgh0202](https://github.com/wsgh0202))
## v2.10.3
`2025-07-22`
<details>
<summary>正式版用户将获得 v2.10.1-preview ~ v2.10.2-preview 的所有改动, 点击展开查看</summary>
- 新增 `稍后再看页面-重定向` 组件. (PR #5205 by [undefined](https://github.com/magicFeirl))
> 重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。
- `隐藏热搜` 支持隐藏直播间搜索框的热搜. (PR #5207 by [undefined](https://github.com/magicFeirl))
- 修复 `视频链接增强` 对 niconico 链接的处理. (PR #5215 by [Alan Ye](https://github.com/at-wr))
- 新增 `动态分组过滤` 组件. (PR #5217 by [Rinne](https://github.com/OharaRinneY))
> 按照关注分组筛选动态
- `直播信息扩充` 支持配置置顶和隐藏的关注列表. (#5183)
- 新增组件 `一键点亮直播间粉丝勋章`. (PR #5171 by [undefined](https://github.com/magicFeirl))
> 在直播间页面的功能面板添加一键点亮粉丝勋章功能,仅适用于有粉丝勋章且正在直播的直播间。原理:发送一个 300 次点赞的请求点亮粉丝勋章。
- 新增组件 `下载表情`. (PR #5176 by [Pencil](https://github.com/pencilqaq))
> 支持下载 up 主专属表情包,处于任意直播间页面时,下载按钮会在 `功能` 面板显示,以压缩包形式保存。
- 新增组件 `视频链接增强`. (PR #5197 by [Alan Ye](https://github.com/at-wr))
> 这个组件会将视频简介中的普通网址转换为可点击的链接,并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。
>
> 例如: `https://acg.tv/sm37507315``https://www.nicovideo.jp/watch/sm37507315`
>
> <i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>
- `自定义顶栏` 新增 `使用主题色 Logo` 选项, 关闭时可以使用黑白纯色的 Logo. (#4996)
- `自定义顶栏` 更新 `游戏中心`, `漫画`, `赛事` 的弹窗, 废弃 `直播` 的弹窗. (#5055)
</details>
(以下其实都是 6 月就更新了, 只要你的 `自动更新器` 正常使用, 应该早已收到更新了, 这些改动无需更新本体)
✨新增
- `显示视频投稿时间` 支持在合集类页面显示. (#5247, PR #5266 by [呼乎户](https://github.com/wisokey))
- `动态过滤` 支持过滤新出现的热搜面板. (#5273, PR #5276 by [Whalko](https://github.com/RtYkk))
🐛修复
- 修复 `夜间模式` 在首页 svg 图标的颜色. (PR #5240 by [hyrious](https://github.com/hyrious))
- 修复 `自动移出稍后再看` 重复调用接口的 bug. (PR #5241 by [sunfkny](https://github.com/sunfkny))
- 修复 `关注时间显示` 失效. (#5096, #5212, PR 5282 by [CNOCM](https://github.com/CNOCM))
## v2.10.2-preview
`2025-05-30`

View File

@ -30,6 +30,9 @@ https://afdian.com/a/the1812?tab=sponsor
| 时间 | 用户名 | 单号后4位 | 金额 |
| ------------------- | --------------------- | --------- | ------- |
| 2025.07.12 16:44:24 | 爱音allin | 3957 | ¥5.00 |
| 2025.06.29 10:53:44 | f*x | 9352 | ¥10.00 |
| 2025.06.10 01:22:23 | *匆 | 5332 | ¥5.00 |
| 2025.05.21 19:55:02 | mobmaster69 | 7644 | ¥5.00 |
| 2025.05.20 02:38:45 | Duane | 3937 | ¥5.00 |
| 2025.05.15 22:12:22 | 匿名 | 3901 | ¥23.33 |

View File

@ -71,6 +71,14 @@
"fullRelativePath": "../../registry/dist/components/feeds/full-title.js",
"fullAbsolutePath": "registry/dist/components/feeds/full-title.js"
},
{
"type": "component",
"name": "feedsGroupFilter",
"displayName": "动态分组过滤",
"description": "by [@Rinne](https://github.com/OharaRinneY)\n\n按照关注分组筛选动态\r\n",
"fullRelativePath": "../../registry/dist/components/feeds/group-filter.js",
"fullAbsolutePath": "registry/dist/components/feeds/group-filter.js"
},
{
"type": "component",
"name": "hideFeedsCommentPreview",
@ -111,6 +119,14 @@
"fullRelativePath": "../../registry/dist/components/live/badge-helper.js",
"fullAbsolutePath": "registry/dist/components/live/badge-helper.js"
},
{
"type": "component",
"name": "badgeKeepalive",
"displayName": "一键点亮直播间粉丝勋章",
"description": "by [@magicFeirl](https://github.com/magicFeirl)\n\n在直播间页面的功能面板添加一键点亮粉丝勋章功能仅适用于有粉丝勋章且正在直播的直播间。原理发送一个 300 次点赞的请求点亮粉丝勋章。",
"fullRelativePath": "../../registry/dist/components/live/badge-keepalive.js",
"fullAbsolutePath": "registry/dist/components/live/badge-keepalive.js"
},
{
"type": "component",
"name": "liveChatPanelFit",
@ -503,6 +519,14 @@
"fullRelativePath": "../../registry/dist/components/touch/player-gestures.js",
"fullAbsolutePath": "registry/dist/components/touch/player-gestures.js"
},
{
"type": "component",
"name": "activeVideoLinks",
"displayName": "视频链接增强",
"description": "by [@Alan Ye](https://github.com/at-wr)\n\n这个组件会将视频简介中的普通网址转换为可点击的链接并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。\r\n\r\n例如: `https://acg.tv/sm37507315` → `https://www.nicovideo.jp/watch/sm37507315`\r\n\r\n<i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>\r\n",
"fullRelativePath": "../../registry/dist/components/utils/active-video-links.js",
"fullAbsolutePath": "registry/dist/components/utils/active-video-links.js"
},
{
"type": "component",
"name": "albumPubTimeShow",
@ -591,6 +615,14 @@
"fullRelativePath": "../../registry/dist/components/utils/download-audio.js",
"fullAbsolutePath": "registry/dist/components/utils/download-audio.js"
},
{
"type": "component",
"name": "downloadEmoticons",
"displayName": "下载表情",
"description": "by [@Pencilqaq](https://github.com/pencilqaq)\n\n## 下载up主专属表情包\r\n\r\n支持下载up主专属表情包处于任意直播间页面时下载按钮会在`功能`面板显示,以压缩包形式保存。",
"fullRelativePath": "../../registry/dist/components/utils/download-emoticons.js",
"fullAbsolutePath": "registry/dist/components/utils/download-emoticons.js"
},
{
"type": "component",
"name": "imageExporter",
@ -643,7 +675,7 @@
"type": "component",
"name": "subscribeTimeShow",
"displayName": "关注时间显示",
"description": "by [@Light_Quanta](https://github.com/LightQuanta)\n\n在粉丝/关注列表显示关注的具体时间",
"description": "by [@CNOCM](https://github.com/CNOCM)\n\n在粉丝/关注列表及用户主页显示关注的具体时间。",
"fullRelativePath": "../../registry/dist/components/utils/subscribe-time-show.js",
"fullAbsolutePath": "registry/dist/components/utils/subscribe-time-show.js"
},
@ -671,6 +703,14 @@
"fullRelativePath": "../../registry/dist/components/utils/view-cover.js",
"fullAbsolutePath": "registry/dist/components/utils/view-cover.js"
},
{
"type": "component",
"name": "watchlaterPageRedirect",
"displayName": "稍后再看页面-重定向",
"description": "by [@magicFeirl](https://github.com/magicFeirl)\n\n重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。\r\n",
"fullRelativePath": "../../registry/dist/components/utils/watchlater-page-redirect.js",
"fullAbsolutePath": "registry/dist/components/utils/watchlater-page-redirect.js"
},
{
"type": "component",
"name": "watchlaterRedirect",

View File

@ -85,6 +85,17 @@
在顶栏的视频动态中, 无论标题多长总是完全展开.
### [动态分组过滤](../../registry/dist/components/feeds/group-filter.js)
`feedsGroupFilter`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/feeds/group-filter.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/feeds/group-filter.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/feeds/group-filter.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/feeds/group-filter.js)
by [@Rinne](https://github.com/OharaRinneY)
按照关注分组筛选动态
### [隐藏动态评论预览](../../registry/dist/components/feeds/hide-comment-preview.js)
`hideFeedsCommentPreview`
@ -136,6 +147,17 @@
在直播区中, 可从功能面板中直接切换勋章和头衔. 默认显示 256 个 (同时也是上限), 可在选项中修改.
### [一键点亮直播间粉丝勋章](../../registry/dist/components/live/badge-keepalive.js)
`badgeKeepalive`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/live/badge-keepalive.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/live/badge-keepalive.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/live/badge-keepalive.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/live/badge-keepalive.js)
by [@magicFeirl](https://github.com/magicFeirl)
在直播间页面的功能面板添加一键点亮粉丝勋章功能,仅适用于有粉丝勋章且正在直播的直播间。原理:发送一个 300 次点赞的请求点亮粉丝勋章。
### [直播间网页全屏自适应](../../registry/dist/components/live/chat-panel-fit.js)
`liveChatPanelFit`
@ -657,6 +679,21 @@ by [@RieN7](https://github.com/rien7),[@ZiuChen](https://github.com/ZiuChen),[@L
- 进度调整可在左上角和右上角取消
- 进度调整时在不同位置滑动, 可以使用3档不同的灵敏度.
### [视频链接增强](../../registry/dist/components/utils/active-video-links.js)
`activeVideoLinks`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/utils/active-video-links.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/utils/active-video-links.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/active-video-links.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/active-video-links.js)
by [@Alan Ye](https://github.com/at-wr)
这个组件会将视频简介中的普通网址转换为可点击的链接,并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。
例如: `https://acg.tv/sm37507315``https://www.nicovideo.jp/watch/sm37507315`
<i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>
### [相簿发布时间显示](../../registry/dist/components/utils/album-time-show.js)
`albumPubTimeShow`
@ -783,6 +820,19 @@ by [@snowraincloud](https://github.com/snowraincloud)
> 需要进入音频的详细信息页面才能下载, 在其他页面中此按钮将不可点击.
### [下载表情](../../registry/dist/components/utils/download-emoticons.js)
`downloadEmoticons`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/utils/download-emoticons.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/utils/download-emoticons.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/download-emoticons.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/download-emoticons.js)
by [@Pencilqaq](https://github.com/pencilqaq)
## 下载up主专属表情包
支持下载up主专属表情包处于任意直播间页面时下载按钮会在`功能`面板显示,以压缩包形式保存。
### [图片批量导出](../../registry/dist/components/utils/image-exporter.js)
`imageExporter`
@ -896,9 +946,9 @@ by [@Light_Quanta](https://github.com/LightQuanta)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/subscribe-time-show.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/subscribe-time-show.js)
by [@Light_Quanta](https://github.com/LightQuanta)
by [@CNOCM](https://github.com/CNOCM)
在粉丝/关注列表显示关注的具体时间
在粉丝/关注列表及用户主页显示关注的具体时间
### [网址参数清理](../../registry/dist/components/utils/url-params-clean.js)
`urlParamsClean`
@ -927,6 +977,17 @@ by [@Light_Quanta](https://github.com/LightQuanta)
在视频页面中, 可从功能面板中查看封面.
### [稍后再看页面-重定向](../../registry/dist/components/utils/watchlater-page-redirect.js)
`watchlaterPageRedirect`
**jsDelivr:** [`Stable`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@master/registry/dist/components/utils/watchlater-page-redirect.js) / [`Preview`](https://cdn.jsdelivr.net/gh/the1812/Bilibili-Evolved@preview/registry/dist/components/utils/watchlater-page-redirect.js)
**GitHub:** [`Stable`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/master/registry/dist/components/utils/watchlater-page-redirect.js) / [`Preview`](https://raw.githubusercontent.com/the1812/Bilibili-Evolved/preview/registry/dist/components/utils/watchlater-page-redirect.js)
by [@magicFeirl](https://github.com/magicFeirl)
重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。
### [稍后再看重定向](../../registry/dist/components/utils/watchlater-redirect.js)
`watchlaterRedirect`

View File

@ -0,0 +1,57 @@
<template>
<div class="extend-feeds-live-extra-options">
<div class="extend-feeds-live-extra-option">
<div class="extend-feeds-live-extra-option-header">置顶列表</div>
<div class="extend-feeds-live-extra-option-description">
配置直播信息扩充要展示的置顶列表置顶列表中的用户会排序到上方
</div>
<FollowingListSelect :value="pinnedListID" @change="handlePinnedListChange" />
</div>
<div class="extend-feeds-live-extra-option">
<div class="extend-feeds-live-extra-option-header">隐藏列表</div>
<div class="extend-feeds-live-extra-option-description">
配置直播信息扩充要展示的隐藏列表隐藏列表中的用户会被隐藏优先级高于置顶列表
</div>
<FollowingListSelect :value="hiddenListID" @change="handleHiddenListChange" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FollowingListSelect from './FollowingListSelect.vue'
import { getComponentSettings } from '@/core/settings'
import { ExtendFeedsLiveOptions } from './options'
import { FollowingListID } from './types'
const { options } = getComponentSettings<ExtendFeedsLiveOptions>('extendFeedsLive')
const pinnedListID = ref(options.pinnedListID)
const handlePinnedListChange = (value: FollowingListID) => {
options.pinnedListID = value
pinnedListID.value = value
}
const hiddenListID = ref(options.hiddenListID)
const handleHiddenListChange = (value: FollowingListID) => {
options.hiddenListID = value
hiddenListID.value = value
}
</script>
<style lang="scss">
@import 'common';
.extend-feeds-live-extra-options {
margin-top: 8px;
@include v-stretch(12px);
.extend-feeds-live-extra-option {
@include v-stretch(8px);
&-header {
@include semi-bold();
}
&-description {
font-size: 12px;
opacity: 0.75;
}
}
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<VDropdown
:items="followingList"
:key-mapper="item => item.id"
class="be-live-following-list-select"
:value="selectedItem"
@change="handleFollowingListChange"
>
<template #item="{ item }">
<span class="be-live-following-list-item"> {{ item.displayName }} </span>
</template>
</VDropdown>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { VDropdown } from '@/ui'
import { bilibiliApi, getJsonWithCredentials } from '@/core/ajax'
import { FollowingListID, FollowingListItem, RawFollowingListItem } from './types'
import { UnselectedListID } from './options'
const props = defineProps<{
value: FollowingListID
}>()
const emit = defineEmits<{
(e: 'change', value: FollowingListID): void
}>()
const followingListID = computed<FollowingListID>(() => props.value)
const handleFollowingListChange = (value: FollowingListItem) => {
emit('change', value.id)
}
const unselectedListItem = reactive<FollowingListItem>({
id: UnselectedListID,
displayName: '<未选择>',
count: 0,
})
const followingList = ref<FollowingListItem[]>([])
const selectedItem = computed(() => {
return followingList.value.find(item => item.id === followingListID.value) ?? unselectedListItem
})
const loadFollowingList = async () => {
const list = await bilibiliApi<RawFollowingListItem[]>(
getJsonWithCredentials('https://api.bilibili.com/x/relation/tags'),
)
followingList.value = [
unselectedListItem,
...list.map(item => ({
id: item.tagid,
displayName: item.name,
count: item.count,
})),
]
if (
props.value !== UnselectedListID &&
!followingList.value.some(item => item.id === props.value)
) {
followingList.value.unshift({
id: props.value,
displayName: '<已删除的分组>',
count: 0,
})
}
}
loadFollowingList()
</script>

View File

@ -6,7 +6,7 @@
<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="refresh">
<div v-if="loaded" class="be-live-list-refresh" title="刷新" @click="fetchLiveList">
<VIcon icon="mdi-refresh" :size="16" />
</div>
<a
@ -49,21 +49,17 @@
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { VIcon, TextBox, DpiImage, VEmpty, VLoading } from '@/ui'
import { getJsonWithCredentials, responsiveGetPages } from '@/core/ajax'
interface LiveInfo {
cover: string
face: string
uname: string
title: string
roomid: number
pic: string
online: number
uid: number
link: string
}
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')
@ -71,7 +67,11 @@ const decodeTitle = (title: string) => {
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('')
@ -88,37 +88,95 @@ const filteredItems = computed(() => {
return items.value
})
const fetchRecommendItems = async (): Promise<LiveInfo[]> => {
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,
}
})
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 sortByRecommend = (feedItems: LiveInfo[], recommendItems: LiveInfo[]) => {
const mergeRecommendLiveInfos = (liveInfos: LiveInfo[], recommendLiveInfos: LiveInfo[]) => {
// recommendItems pic online ui
// 便 recommendItems item
const recommendRoomIds = recommendItems.map(item => item.roomid)
const feedConcatItems = feedItems.filter(item => !recommendRoomIds.includes(item.roomid))
return lodash.concat(recommendItems, feedConcatItems)
const recommendRoomIds = recommendLiveInfos.map(item => item.roomid)
const feedConcatItems = liveInfos.filter(item => !recommendRoomIds.includes(item.roomid))
return lodash.concat(recommendLiveInfos, feedConcatItems)
}
const refresh = async () => {
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
@ -145,15 +203,28 @@ const refresh = async () => {
getTotal: json => lodash.get(json, 'data.count', 0),
})
const [allItems, recommendItems] = await Promise.all([promise, fetchRecommendItems()])
items.value = sortByRecommend(allItems, recommendItems)
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(() => {
refresh()
initializeLiveList()
})
onBeforeUnmount(() => {
removeComponentListener('extendFeedsLive.pinnedListID', initializeLiveList)
removeComponentListener('extendFeedsLive.hiddenListID', initializeLiveList)
})
</script>
<style lang="scss">

View File

@ -3,6 +3,7 @@ import { styledComponentEntry } from '@/components/styled-component'
import { select } from '@/core/spin-query'
import { mountVueComponent } from '@/core/utils'
import { useScopedConsole } from '@/core/utils/log'
import { options } from './options'
const entry = async () => {
const console = useScopedConsole('extendFeedsLive')
@ -46,4 +47,6 @@ export const component = defineComponentMetadata({
entry: styledComponentEntry(() => import('./extend-feeds-live.scss'), entry),
tags: [componentsTags.feeds, componentsTags.live],
urlInclude: [/^https:\/\/t\.bilibili\.com\/$/],
extraOptions: () => import('./ExtraOptions.vue').then(m => m.default),
options,
})

View File

@ -0,0 +1,19 @@
import { defineOptionsMetadata, OptionsOfMetadata } from '@/components/define'
import { FollowingListID } from './types'
export const UnselectedListID: FollowingListID = -1
export const options = defineOptionsMetadata({
pinnedListID: {
defaultValue: UnselectedListID as FollowingListID,
displayName: '置顶列表 ID',
hidden: true,
},
hiddenListID: {
defaultValue: UnselectedListID as FollowingListID,
displayName: '隐藏列表 ID',
hidden: true,
},
})
export type ExtendFeedsLiveOptions = OptionsOfMetadata<typeof options>

View File

@ -0,0 +1,69 @@
export type FollowingListID = number
export interface LiveInfo {
cover: string
face: string
uname: string
title: string
roomid: number
pic: string
online: number
uid: number
link: string
}
export interface FollowingUserInfo {
mid: number
attribute: number
special: number
contract_info: Record<string, unknown>
uname: string
face: string
sign: string
face_nft: number
official_verify: {
type: number
desc: string
}
vip: {
vipType: number
vipDueDate: number
dueRemark: string
accessStatus: number
vipStatus: number
vipStatusWarn: string
themeType: number
label: {
path: string
text: string
label_theme: string
text_color: string
bg_style: number
bg_color: string
border_color: string
}
avatar_subscript: number
nickname_color: string
avatar_subscript_url: string
}
name_render: Record<string, unknown>
live: {
live_status: number
jump_url: string
}
nft_icon: string
rec_reason: string
track_id: string
follow_time: string
}
export interface RawFollowingListItem {
tagid: number
name: string
count: number
tip: string
}
export interface FollowingListItem {
id: FollowingListID
displayName: string
count: number
}

View File

@ -0,0 +1,156 @@
<template>
<div class="group-filter-panel">
<div class="group-filter-header">
<h1>分组</h1>
<switch-box v-model="allChecked" />
</div>
<div v-for="(group, index) in groups" :key="index" class="group-item">
<CheckBox v-model="group.checked">
{{ group.name }}
</CheckBox>
</div>
</div>
</template>
<script lang="ts">
import { CheckBox, SwitchBox } from '@/ui'
import { bilibiliApi, getJsonWithCredentials, getPages } from '@/core/ajax'
import { FeedsCard, forEachFeedsCard } from '@/components/feeds/api'
import { getUID } from '@/core/utils'
interface Group {
name: string
checked: boolean
id: number
}
let cardsManager: typeof import('@/components/feeds/api').feedsCardsManager
export default Vue.extend({
components: {
SwitchBox,
CheckBox,
},
data() {
return {
groups: [],
followingMap: new Map<string, number[]>(), // username -> tagid[]
selectedGroupIds: [],
allChecked: true,
} as {
groups: Group[]
followingMap: Map<string, number[]>
selectedGroupIds: number[]
allChecked: boolean
}
},
watch: {
groups: {
handler(newGroups: Group[]) {
this.selectedGroupIds = newGroups.filter(group => group.checked).map(group => group.id)
cardsManager.cards.forEach(card => {
this.updateCard(lodash.clone(card))
})
},
deep: true,
immediate: true,
},
allChecked: {
handler(newChecked: boolean) {
for (const group of this.groups) {
group.checked = newChecked
}
},
},
},
async mounted() {
cardsManager = await forEachFeedsCard({
added: card => {
this.updateCard(lodash.clone(card))
},
})
// fetch groups
this.groups = await bilibiliApi<Array<any>>(
getJsonWithCredentials('https://api.bilibili.com/x/relation/tags'),
'分组信息获取失败',
).then(res =>
res.map(value => ({
name: value.name,
checked: true,
id: value.tagid,
})),
)
const uid = getUID()
// fetch following
const allPages = await getPages({
api: page =>
getJsonWithCredentials(
`https://api.bilibili.com/x/relation/followings?vmid=${uid}&pn=${page}&ps=50`,
),
getList: json => json.data.list,
getTotal: json => json.data.total,
})
allPages.forEach(user => {
this.followingMap.set(user.uname, user.tag)
})
},
methods: {
updateCard(card: FeedsCard) {
// usernametag
const userTagIds: number[] = this.followingMap.get(card.username)
if (!userTagIds || !this.selectedGroupIds) {
return
}
if (!userTagIds.some(item => this.selectedGroupIds.includes(item))) {
card.element.classList.add('group-filter-hide-feed')
} else {
card.element.classList.remove('group-filter-hide-feed')
}
},
},
})
</script>
<style lang="scss">
.group-filter-hide-feed {
display: none !important;
}
.group-filter-panel {
background-color: white;
font-size: 12px;
width: 100%;
border-radius: 4px;
box-sizing: border-box;
flex-direction: column;
padding: 12px 16px;
.group-filter-header {
cursor: pointer;
padding-bottom: 14px;
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
h1 {
font-weight: normal;
font-size: 16px;
margin: 0;
}
}
.group-item {
display: flex;
flex-direction: row;
font-size: 14px;
}
body.dark & {
color: #eee;
background-color: #444;
}
}
</style>

View File

@ -0,0 +1 @@
按照关注分组筛选动态

View File

@ -0,0 +1,34 @@
import { defineComponentMetadata } from '@/components/define'
import { feedsCardsManager } from '@/components/feeds/api'
const entry = async () => {
const { select } = await import('@/core/spin-query')
let leftPanel: HTMLElement
if (feedsCardsManager.managerType === 'v2') {
const leftAside = await select('.bili-dyn-home--member aside.left')
const section = document.createElement('section')
section.classList.add('group-filter-section')
leftAside.insertAdjacentElement('afterbegin', section)
leftPanel = section
} else {
leftPanel = await select('.home-container .left-panel')
}
if (leftPanel === null) {
return
}
const FilterPanel = await import('./FilterPanel.vue')
const { mountVueComponent } = await import('@/core/utils')
leftPanel.insertAdjacentElement('afterbegin', mountVueComponent(FilterPanel).$el)
}
export const component = defineComponentMetadata({
name: 'feedsGroupFilter',
entry,
displayName: '动态分组过滤',
author: { name: 'Rinne', link: 'https://github.com/OharaRinneY' },
urlInclude: [
// 仅动态首页
/^https:\/\/t\.bilibili\.com\/$/,
],
tags: [componentsTags.feeds],
})

View File

@ -0,0 +1,61 @@
<template>
<div class="container">
<p class="title">一键点亮粉丝勋章</p>
<TextBox
placeholder="直播间ID"
:text="roomid"
@change="handleRoomIdChange"
:changeOnBlur="true"
></TextBox>
<AsyncButton @click="handleKeepAliveRequest">点亮!</AsyncButton>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { TextBox, AsyncButton } from '@/ui'
import { validateRoomId, getLiveRoomId, keepAliveRequest } from './utils'
import { Toast } from '@/core/toast'
const roomid = ref(getLiveRoomId())
const handleRoomIdChange = (value: string) => {
if (validateRoomId(value)) {
roomid.value = value
} else {
roomid.value = ''
}
}
const handleKeepAliveRequest = async () => {
if (!roomid.value) {
return
}
try {
await keepAliveRequest(roomid.value)
Toast.success('发送点亮勋章请求成功', '提示')
} catch ({ message }) {
Toast.error(`勋章点亮失败,原因: ${message}`, '提示')
}
}
</script>
<style lang="scss" scoped>
@import 'common';
.container {
display: flex;
flex-flow: column;
box-shadow: 0 0 0 1px #8884;
order: -2;
border-radius: 4px;
padding: 6px 6px 6px 10px;
@include v-stretch(6px);
.title {
@include semi-bold();
}
}
</style>

View File

@ -0,0 +1 @@
在直播间页面的功能面板添加一键点亮粉丝勋章功能,仅适用于有粉丝勋章且正在直播的直播间。原理:发送一个 300 次点赞的请求点亮粉丝勋章。

View File

@ -0,0 +1,20 @@
import { defineComponentMetadata } from '@/components/define'
import { getUID } from '@/core/utils'
export const component = defineComponentMetadata({
name: 'badgeKeepalive',
displayName: '一键点亮直播间粉丝勋章',
entry: none,
reload: none,
unload: none,
author: {
name: 'magicFeirl',
link: 'https://github.com/magicFeirl',
},
tags: [componentsTags.live],
widget: {
component: () => import('./BadgeKeepalive.vue').then(m => m.default),
condition: () => Boolean(getUID()),
},
urlInclude: ['//live.bilibili.com'],
})

View File

@ -0,0 +1,55 @@
import { postTextWithCredentials, getJsonWithCredentials } from '@/core/ajax'
import { getCsrf, getUID } from '@/core/utils'
// 获取当前直播间号
export function getLiveRoomId(): string {
const matched = location.href.match(/live.bilibili.com\/(\d+)/)
return matched ? matched[1] : ''
}
export function validateRoomId(value: string): boolean {
return /^\d+$/.test(value)
}
function validateJSON(data) {
if (data.code !== 0) {
throw new Error(data.message)
}
return data.data
}
export async function getLiveRoomUserInfo(room_id: string) {
const data = await getJsonWithCredentials(
`https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByUser?room_id=${room_id}`,
)
return validateJSON(data)
}
export async function keepAliveRequest(room_id: string, click_time = '300') {
// 需要先获取直播间房主的 UID
const data = await getLiveRoomUserInfo(room_id)
const { curr_weared } = data.medal
if (!curr_weared) {
throw new Error(`暂未获得直播间 ${room_id} 的粉丝勋章`)
}
const anchor_id = curr_weared.target_id
const params = {
click_time,
room_id,
anchor_id,
uid: getUID(),
csrf: getCsrf(),
}
const baseURL =
'https://api.live.bilibili.com/xlive/app-ucenter/v1/like_info_v3/like/likeReportV3'
return validateJSON(
JSON.parse(await postTextWithCredentials(baseURL, new URLSearchParams(params))),
)
}

View File

@ -4,9 +4,10 @@ import { ranking } from './ranking/ranking'
import { userInfo } from './user-info/user-info'
import { logo } from './logo/logo'
import { home } from './home/home'
import { gamesIframe, livesIframe, mangaIframe } from './iframe/iframe'
import { games } from './games/games'
import { blanks } from './flexible-blank/flexible-blank'
import { bangumi, music, shop, match, creations } from './simple-links/simple-links'
import { bangumi, music, shop, creations, lives } from './simple-links/simple-links'
import { match } from './match/match'
import { upload } from './upload/upload'
import { search } from './search/search'
import { feeds } from './feeds/feeds'
@ -14,6 +15,7 @@ import { subscriptions } from './subscriptions/subscriptions'
import { watchlater } from './watchlater/watchlater'
import { favorites } from './favorites/favorites'
import { history } from './history/history'
import { manga } from './manga/manga'
const [blank1, blank2, blank3, blank4] = blanks
export const getBuiltInItems = (): CustomNavbarItemInit[] => [
@ -23,11 +25,11 @@ export const getBuiltInItems = (): CustomNavbarItemInit[] => [
bangumi,
ranking,
music,
gamesIframe,
livesIframe,
games,
lives,
shop,
match,
mangaIframe,
manga,
blank2,
search,
blank3,

View File

@ -3,6 +3,7 @@ import { addComponentListener } from '@/core/settings'
import { isIframe, isNotHtml, matchUrlPattern, mountVueComponent } from '@/core/utils'
import { setupNotifyStyle } from './notify-style'
import { setupLinkPopupContentAlignStyle } from './link-popup-content-align-style'
import { migrateOrders } from './orders-migration'
export const entry: ComponentEntry = async ({ metadata: { name } }) => {
// const url = document.URL.replace(location.search, '')
@ -51,6 +52,7 @@ export const entry: ComponentEntry = async ({ metadata: { name } }) => {
if (matchUrlPattern('https://www.bilibili.com/account/history')) {
document.body.classList.add('history-page')
}
migrateOrders()
const CustomNavbar = await import('./CustomNavbar.vue')
const customNavbar: Vue & {
styles: string[]

View File

@ -0,0 +1,179 @@
<script setup lang="ts">
import { ref } from 'vue'
import { VLoading } from '@/ui'
import type { GameInfo } from './types'
import { bilibiliApi, getJson } from '@/core/ajax'
import { usePopper } from '../mixins'
import { CustomNavbarItem } from '../custom-navbar-item'
const props = defineProps<{
item: CustomNavbarItem
container: HTMLElement
}>()
const popper = usePopper(props)
const gameInfo = ref<GameInfo | undefined>(undefined)
const loading = ref(true)
const loadGameInfo = async () => {
if (gameInfo.value !== undefined) {
return
}
const response = await bilibiliApi<GameInfo>(
getJson('https://le3-api.game.bilibili.com/pc/game/nav/info'),
)
gameInfo.value = response
loading.value = false
}
defineExpose({
popupShow() {
popper.popupShow()
loadGameInfo()
},
})
</script>
<template>
<div class="custom-navbar-games-popup">
<div v-if="loading" class="loading-container">
<VLoading />
</div>
<div v-if="gameInfo" class="custom-navbar-games-panel">
<div class="custom-navbar-games-left-panel">
<a class="banner-game-card" :href="gameInfo.banner.href" :title="gameInfo.banner.title">
<img :src="gameInfo.banner.poster" :alt="gameInfo.banner.title" />
<div class="banner-game-card-title">
{{ gameInfo.banner.title }}
</div>
</a>
<div class="panel-game-cards">
<a
v-for="card of gameInfo.panel"
:key="card.href"
class="panel-game-card"
:href="card.href"
:title="card.title"
>
<img :src="card.poster" :alt="card.title" />
<div class="panel-game-card-title">
{{ card.title }}
</div>
</a>
</div>
</div>
<div class="custom-navbar-games-panel-separator"></div>
<div class="custom-navbar-games-right-panel">
<div class="list-game-cards-title">新游预告</div>
<div class="list-game-cards">
<a
v-for="card of gameInfo.list.slice(0, 8)"
:key="card.href"
class="list-game-card"
:href="card.href"
:title="card.title"
>
{{ card.title }}
</a>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
@import 'common';
.custom-navbar-games-popup {
width: 420px;
font-size: 12px;
$gap: 12px;
@include v-stretch();
.loading-container {
flex-grow: 1;
@include v-center();
justify-content: center;
height: 240px;
font-size: 14px;
}
.custom-navbar-games-panel {
@include h-stretch($gap);
padding: $gap;
}
.custom-navbar-games-left-panel {
flex-basis: 60%;
min-width: 0;
@include v-stretch($gap);
.banner-game-card {
position: relative;
overflow: hidden;
background-color: #8882;
@include v-stretch();
@include round-corner();
img {
transition: transform 0.2s ease-out;
aspect-ratio: 15 / 8;
}
&:hover img {
transform: scale(1.05);
}
&-title {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
font-size: 12px;
color: #fff;
padding: 8px;
background-image: linear-gradient(to top, #0008, transparent);
}
}
.panel-game-cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: $gap;
.panel-game-card {
@include v-stretch(6px);
min-width: 0;
transition: color 0.2s ease-out;
img {
background-color: #8882;
border-radius: 16px;
aspect-ratio: 1 / 1;
}
&:hover {
color: var(--theme-color) !important;
}
&-title {
@include max-line(2);
text-align: center;
}
}
}
}
.custom-navbar-games-right-panel {
min-width: 0;
flex: 1;
@include v-stretch($gap);
padding: 6px 4px;
.list-game-cards-title {
font-size: large;
@include semi-bold();
}
.list-game-cards {
@include v-stretch();
.list-game-card {
@include single-line();
padding: 4px 0;
opacity: 0.6;
transition: all 0.2s ease-out;
&:hover {
color: var(--theme-color) !important;
opacity: 1;
}
}
}
}
.custom-navbar-games-panel-separator {
width: 1px;
background-color: #8882;
}
}
</style>

View File

@ -0,0 +1,14 @@
import { CustomNavbarItemInit } from '../custom-navbar-item'
export const games: CustomNavbarItemInit = {
name: 'games',
displayName: '游戏中心',
content: '游戏中心',
touch: true,
href: 'https://game.bilibili.com/',
boundingWidth: 420,
noPopupPadding: true,
popupContent: () => import('./GamesPopup.vue').then(m => m.default),
}

View File

@ -0,0 +1,11 @@
export interface GameListItem {
href: string
title: string
poster: string
}
export interface GameInfo {
panel: GameListItem[]
list: GameListItem[]
banner: GameListItem
}

View File

@ -1,21 +0,0 @@
<template>
<iframe :src="item.src" frameborder="0" :width="item.width" :height="item.height"></iframe>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { NavbarIframeConfig } from './iframe'
import { popperMixin } from '../mixins'
import { CustomNavbarItem } from '../custom-navbar-item'
export default Vue.extend({
name: 'IframePopup',
mixins: [popperMixin],
props: {
item: {
type: CustomNavbarItem as unknown as PropType<CustomNavbarItem & NavbarIframeConfig>,
required: true,
},
},
})
</script>

View File

@ -1,50 +0,0 @@
import { CustomNavbarItemInit } from '../custom-navbar-item'
export interface NavbarIframeConfig {
src: string
href: string
width: number
height: number
lazy: boolean
displayName: string
iframeName: string
}
const getIframeItem = (config: NavbarIframeConfig): CustomNavbarItemInit & NavbarIframeConfig => ({
...config,
name: `${config.iframeName}Iframe`,
content: config.displayName,
touch: true,
popupContent: () => import('./IframePopup.vue').then(m => m.default),
boundingWidth: config.width,
noPopupPadding: true,
transparentPopup: true,
})
export const gamesIframe = getIframeItem({
src: 'https://www.bilibili.com/page-proxy/game-nav.html',
href: 'https://game.bilibili.com/',
width: 680,
height: 260,
lazy: true,
displayName: '游戏中心',
iframeName: 'games',
})
export const livesIframe = getIframeItem({
src: 'https://live.bilibili.com/blackboard/dropdown-menu.html',
href: 'https://live.bilibili.com',
width: 528,
height: 266,
lazy: true,
displayName: '直播',
iframeName: 'lives',
})
export const mangaIframe = getIframeItem({
src: 'https://manga.bilibili.com/eden/bilibili-nav-panel.html',
href: 'https://manga.bilibili.com',
width: 720,
height: 266,
lazy: true,
displayName: '漫画',
iframeName: 'manga',
})

View File

@ -58,7 +58,11 @@ const options = defineOptionsMetadata({
},
seasonLogo: {
defaultValue: false,
displayName: '使用季节Logo',
displayName: '使用季节 Logo',
},
themeLogo: {
defaultValue: true,
displayName: '使用主题色 Logo',
},
touch: {
defaultValue: false,

View File

@ -1,6 +1,11 @@
<template>
<div class="custom-navbar-logo-container">
<VIcon v-if="!seasonLogoUrl" icon="logo" class="custom-navbar-logo"></VIcon>
<VIcon
v-if="!seasonLogoUrl"
icon="logo"
class="custom-navbar-logo"
:class="{ theme: useThemeColor }"
></VIcon>
<div
v-else
class="custom-navbar-logo season"
@ -22,6 +27,7 @@ export default Vue.extend({
data() {
return {
seasonLogoUrl: '',
useThemeColor: true,
}
},
watch: {
@ -46,6 +52,13 @@ export default Vue.extend({
},
true,
)
addComponentListener(
'customNavbar.themeLogo',
async (value: boolean) => {
this.useThemeColor = value
},
true,
)
},
})
</script>
@ -63,9 +76,15 @@ export default Vue.extend({
width: calc(var(--navbar-height) * 1.3);
}
.custom-navbar-logo {
.custom-navbar:not(.fill) & {
&:not(.season) {
.custom-navbar:not(.fill) &:not(.season) {
body.dark & {
color: #fff;
}
&.theme {
color: var(--theme-color);
body.dark & {
color: var(--theme-color);
}
}
}
&.be-icon {

View File

@ -0,0 +1,231 @@
<script setup lang="ts">
import { ref } from 'vue'
import { VLoading } from '@/ui'
import { bilibiliApi } from '@/core/ajax'
import { CustomNavbarItem } from '../custom-navbar-item'
import { usePopper } from '../mixins'
import type { MangaRecommendItem, MangaHotItem } from './types'
const props = defineProps<{
item: CustomNavbarItem
container: HTMLElement
}>()
const popper = usePopper(props)
const loading = ref(true)
const recommendItems = ref<MangaRecommendItem[] | undefined>(undefined)
const hotItems = ref<MangaHotItem[] | undefined>(undefined)
const previewCard = ref<MangaHotItem | undefined>(undefined)
const loadRecommendItems = async () => {
if (recommendItems.value !== undefined) {
return
}
recommendItems.value = await bilibiliApi<MangaRecommendItem[]>(
fetch('https://manga.bilibili.com/twirp/comic.v1.Comic/Recommend?device=pc&platform=web', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
JsonData: JSON.stringify([
{
pool_id: 500003,
num: 4,
},
]),
}),
}).then(r => r.json()),
)
}
const loadHotItems = async () => {
if (hotItems.value !== undefined) {
return
}
hotItems.value = await bilibiliApi<MangaHotItem[]>(
fetch('https://manga.bilibili.com/twirp/comic.v1.Comic/HomeHot?device=pc&platform=web', {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ type: 0 }),
method: 'POST',
}).then(r => r.json()),
)
}
const loadMangaInfo = async () => {
await Promise.all([loadRecommendItems(), loadHotItems()])
loading.value = false
}
defineExpose({
popupShow() {
popper.popupShow()
loadMangaInfo()
},
})
</script>
<template>
<div class="custom-navbar-manga-popup">
<div v-if="loading" class="loading-container">
<VLoading />
</div>
<div v-if="!loading" class="custom-navbar-manga-panel">
<div class="custom-navbar-manga-left-panel">
<a
v-for="card of recommendItems"
:key="card.season_id"
class="recommend-manga-card"
:href="'https://manga.bilibili.com/detail/mc' + card.season_id"
>
<div class="recommend-manga-card-image">
<img :src="card.horizontal_cover + '@300w.jpg'" :alt="card.title" />
</div>
<div class="recommend-manga-card-title" :title="card.title">{{ card.title }}</div>
</a>
</div>
<div class="custom-navbar-manga-panel-separator"></div>
<div class="custom-navbar-manga-right-panel">
<div class="custom-navbar-manga-hot-title">人气漫画</div>
<div class="custom-navbar-manga-hot-list">
<a
v-for="card of hotItems.slice(0, 7)"
:key="card.comic_id"
class="hot-manga-card"
:title="card.title"
:href="'https://manga.bilibili.com/detail/mc' + card.comic_id"
@mouseenter="previewCard = card"
@mouseleave="previewCard = undefined"
>
<div class="hot-manga-card-title">
{{ card.title }}
</div>
</a>
</div>
<div v-if="previewCard" class="hot-manga-card-preview">
<img :src="previewCard.vertical_cover + '@450h.jpg'" :alt="previewCard.title" />
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
@import 'common';
.custom-navbar-manga-popup {
width: 500px;
font-size: 12px;
$gap: 12px;
@include v-stretch();
.loading-container {
flex-grow: 1;
@include v-center();
justify-content: center;
height: 240px;
font-size: 14px;
}
.custom-navbar-manga-panel {
@include h-stretch($gap);
padding: $gap;
.custom-navbar-manga-left-panel {
min-width: 0;
flex: 0 0 65%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $gap;
.recommend-manga-card {
@include v-stretch(8px);
.recommend-manga-card-image {
@include v-stretch();
@include round-corner();
background: #8882;
aspect-ratio: 16 / 9;
width: 100%;
overflow: hidden;
}
&:hover img {
transform: scale(1.05);
}
&-title {
@include single-line();
}
&:hover {
color: var(--theme-color) !important;
}
}
}
.custom-navbar-manga-right-panel {
position: relative;
min-width: 0;
flex: 1;
@include v-stretch($gap);
padding: 6px 4px;
.custom-navbar-manga-hot-title {
font-size: large;
@include semi-bold();
}
.custom-navbar-manga-hot-list {
@include v-stretch();
counter-reset: manga-item;
.hot-manga-card {
@include h-center(6px);
padding: 4px 0;
&-title {
@include single-line();
transition: color 0.2s ease-out, opacity 0.2s ease-out;
opacity: 0.6;
min-width: 0;
max-width: 100%;
flex: 1 0 0;
}
&::before {
counter-increment: manga-item;
content: counter(manga-item);
@include semi-bold();
opacity: 0.6;
font-style: italic;
font-size: 14px;
}
&:hover .hot-manga-card-title {
color: var(--theme-color) !important;
opacity: 1;
}
&:nth-child(1)::before {
opacity: 1;
color: #e64747;
}
&:nth-child(2)::before {
opacity: 1;
color: #ff6e42;
}
&:nth-child(3)::before {
opacity: 1;
color: #ff9900;
}
}
}
.hot-manga-card-preview {
position: absolute;
pointer-events: none;
top: calc(-1 * #{$gap});
right: calc(100% + #{$gap} + 1px);
height: calc(100% - #{$gap});
background-image: linear-gradient(to left, #fff 75%, transparent);
padding: 18px 18px 18px 60px;
body.dark & {
background-image: linear-gradient(to left, #222 75%, transparent);
}
img {
display: flex;
background: #8882;
height: 100%;
aspect-ratio: 169 / 225;
@include round-corner();
}
}
}
.custom-navbar-manga-panel-separator {
width: 1px;
background-color: #8882;
}
}
}
</style>

View File

@ -0,0 +1,14 @@
import { CustomNavbarItemInit } from '../custom-navbar-item'
export const manga: CustomNavbarItemInit = {
name: 'manga',
displayName: '漫画',
content: '漫画',
touch: true,
href: 'https://manga.bilibili.com/',
boundingWidth: 500,
noPopupPadding: true,
popupContent: () => import('./MangaPopup.vue').then(m => m.default),
}

View File

@ -0,0 +1,52 @@
export interface MangaRecommendItem {
season_id: number
title: string
horizontal_cover: string
square_cover: string
vertical_cover: string
is_finish: number
status: number
last_ord: number
total: number
release_time: string
last_short_title: string
ext: string
recommend_reason: string
discount_type: number
allow_wait_free: boolean
styles: string[]
tags: string[]
rank_info: {
name: string
rank: number
}
score: number
update_day: number
good_review: number
search_num: number
author_name: string[]
rec_tag_id: number
rec_tag: string
rec_tag_type: number
vertical_cover_info: {
cover_id: string
cover_type: number
}
}
export interface MangaHotItem {
comic_id: number
title: string
author: string[]
vertical_cover: string
is_finish: number
last_ord: number
last_short_title: string
styles: {
id: number
name: string
}[]
total: number
last_rank: number
last_ep: number
}

View File

@ -0,0 +1,252 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { VLoading, VButton } from '@/ui'
import { bilibiliApi, getJsonWithCredentials, postTextWithCredentials } from '@/core/ajax'
import { CustomNavbarItem } from '../custom-navbar-item'
import { usePopper } from '../mixins'
import { type MatchInfo, type MatchPreviewItem, MatchHotItemStatus } from './types'
import { getCsrf } from '@/core/utils'
import { logError } from '@/core/utils/log'
const props = defineProps<{
item: CustomNavbarItem
container: HTMLElement
}>()
const popper = usePopper(props)
const loading = ref(true)
const matchInfo = ref<MatchInfo | undefined>(undefined)
const bannerItems = computed(() => matchInfo.value?.banner)
const hotItems = computed(() => matchInfo.value?.hot)
const previewItems = computed(() => matchInfo.value?.preview)
const loadMatchInfo = async () => {
matchInfo.value = reactive(
await bilibiliApi<MatchInfo>(
getJsonWithCredentials('https://api.bilibili.com/x/web-interface/nav/game'),
),
)
loading.value = false
}
const toggleSubscribe = async (card: MatchPreviewItem) => {
const isSubscribed = card.fav_status !== 0
try {
await postTextWithCredentials(
isSubscribed
? 'https://api.bilibili.com/x/esports/fav/del'
: 'https://api.bilibili.com/x/esports/fav/add',
new URLSearchParams({
cid: card.cid.toString(),
csrf: getCsrf(),
}).toString(),
)
if (isSubscribed) {
card.fav_status = 0
} else {
card.fav_status = 1
}
} catch (error) {
logError(error)
}
}
defineExpose({
popupShow() {
popper.popupShow()
loadMatchInfo()
},
})
</script>
<template>
<div class="custom-navbar-match-popup">
<div v-if="loading" class="loading-container">
<VLoading />
</div>
<div v-if="!loading" class="custom-navbar-match-panel">
<div class="custom-navbar-match-left-panel">
<a
v-for="card of bannerItems"
:key="card.jump_url"
class="banner-match-card"
:href="card.jump_url"
>
<img :src="card.cover" />
</a>
</div>
<div class="custom-navbar-match-panel-separator"></div>
<div class="custom-navbar-match-right-panel">
<div class="hot-match-list">
<div class="hot-match-list-title">热门赛事</div>
<a
v-for="card of hotItems"
:key="card.jump_url"
:href="card.jump_url"
:title="card.name"
class="hot-match-card"
>
<div
class="hot-match-card-status"
:class="{ 'in-progress': card.status === MatchHotItemStatus.InProgress }"
>
<template v-if="card.status === MatchHotItemStatus.NotStarted">未开始</template>
<template v-if="card.status === MatchHotItemStatus.InProgress">进行中</template>
<template v-if="card.status === MatchHotItemStatus.Finished">已结束</template>
</div>
<div class="hot-match-card-title">
{{ card.name }}
</div>
</a>
</div>
<div class="preview-match-list">
<div class="preview-match-list-title">精彩预告</div>
<a
v-for="card of previewItems"
:key="card.jump_url"
:href="card.jump_url"
:title="card.name"
class="preview-match-card"
>
<!-- spell-checker: disable-next-line -->
<div class="preview-match-date">{{ card.stime }}</div>
<div class="preview-match-title">{{ card.name }}</div>
<VButton
class="preview-match-subscribe"
:type="card.fav_status === 0 ? 'primary' : 'light'"
@click.prevent="toggleSubscribe(card)"
>
{{ card.fav_status === 0 ? '订阅' : '已订阅' }}
</VButton>
</a>
</div>
</div>
</div>
</div>
</template>
<style lang="scss">
@import 'common';
.custom-navbar-match-popup {
width: 650px;
font-size: 12px;
$gap: 12px;
@include v-stretch();
.loading-container {
flex-grow: 1;
@include v-center();
justify-content: center;
height: 240px;
font-size: 14px;
}
.custom-navbar-match-panel {
@include h-stretch($gap);
padding: $gap;
.custom-navbar-match-left-panel {
min-width: 0;
flex: 0 0 45%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: $gap;
.banner-match-card {
overflow: hidden;
@include round-corner(6px);
&:first-child {
grid-column: 1 / 3;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
background: #8882;
aspect-ratio: 16 / 9;
transition: transform 0.2s ease-out;
}
&:hover img {
transform: scale(1.05);
}
}
}
.custom-navbar-match-panel-separator {
width: 1px;
background-color: #8882;
}
.custom-navbar-match-right-panel {
@include v-stretch($gap);
justify-content: space-between;
min-width: 0;
flex: 1;
padding: 6px 4px;
.hot-match-list-title,
.preview-match-list-title {
font-size: large;
@include semi-bold();
margin-bottom: 6px;
}
.hot-match-list,
.preview-match-list {
@include v-stretch();
counter-reset: match-item;
}
.hot-match-card {
@include h-center(6px);
padding: 5px 0;
&-status {
font-size: 11px;
background-color: #8882;
padding: 1px 4px;
border-radius: 4px;
&.in-progress {
background-color: var(--theme-color);
color: var(--foreground-color);
}
}
&-title {
@include single-line();
transition: color 0.2s ease-out;
min-width: 0;
max-width: 100%;
flex: 1 0 0;
}
&::before {
counter-increment: match-item;
content: counter(match-item);
@include semi-bold();
font-style: italic;
font-size: 14px;
}
&:hover .hot-match-card-title {
color: var(--theme-color) !important;
}
&:nth-child(2)::before {
color: #e64747;
}
&:nth-child(3)::before {
color: #ff6e42;
}
&:nth-child(4)::before {
color: #ff9900;
}
}
.preview-match-card {
@include h-center(4px);
padding: 5px 0;
.preview-match-date {
opacity: 0.6;
}
.preview-match-title {
flex: 1 0 0;
@include single-line();
}
.preview-match-subscribe.be-button {
padding: 2px 6px;
font-size: 11px;
}
&:hover .preview-match-title {
color: var(--theme-color);
}
}
}
}
}
</style>

View File

@ -0,0 +1,14 @@
import { CustomNavbarItemInit } from '../custom-navbar-item'
export const match: CustomNavbarItemInit = {
name: 'match',
displayName: '赛事',
content: '赛事',
touch: true,
href: 'https://www.bilibili.com/v/game/match/',
boundingWidth: 650,
noPopupPadding: true,
popupContent: () => import('./MatchPopup.vue').then(m => m.default),
}

View File

@ -0,0 +1,37 @@
export interface MatchInfo {
hot: MatchHotItem[]
banner: MatchBannerItem[]
preview: MatchPreviewItem[]
}
export enum MatchHotItemStatus {
NotStarted = 1,
InProgress,
Finished,
}
export interface MatchHotItem {
index: number
cid: number
desc: string
status: MatchHotItemStatus
name: string
jump_url: string
}
export interface MatchBannerItem {
index: number
cover: string
jump_url: string
}
export interface MatchPreviewItem {
index: number
cid: number
name: string
desc: string
fav_status: number
jump_url: string
// spell-checker: disable-next-line
stime: string
}

View File

@ -1,3 +1,4 @@
import { onMounted } from 'vue'
import { CustomNavbarItem } from './custom-navbar-item'
export const popperMixin = Vue.extend({
@ -25,3 +26,23 @@ export const popperMixin = Vue.extend({
},
},
})
export interface UsePopperProps {
item: CustomNavbarItem
container: HTMLElement
}
export const usePopper = (props: UsePopperProps) => {
onMounted(() => {
const navBarItem = props.item
const containerElement = props.container
if (containerElement) {
navBarItem?.usePopper(containerElement, containerElement.children[0] as HTMLElement)
}
})
return {
popupShow() {
const navBarItem = props.item
navBarItem?.popper?.update()
},
}
}

View File

@ -0,0 +1,19 @@
import { getComponentSettings } from '@/core/settings'
import type { CustomNavbarOptions } from '.'
export const migrateOrders = () => {
const migrations = [
['gamesIframe', 'games'],
['livesIframe', 'lives'],
['mangaIframe', 'manga'],
]
const { options } = getComponentSettings<CustomNavbarOptions>('customNavbar')
migrations.forEach(([from, to]) => {
const oldOrder = options.order[from]
if (oldOrder !== undefined) {
options.order[to] = oldOrder
delete options.order[from]
}
})
}

View File

@ -32,13 +32,13 @@ export const shop = getSimpleLinkItem({
displayName: '会员购',
href: 'https://show.bilibili.com',
})
export const match = getSimpleLinkItem({
name: 'match',
displayName: '赛事',
href: 'https://www.bilibili.com/v/game/match/',
})
export const creations = getSimpleLinkItem({
name: 'creations',
displayName: '创作中心',
href: 'https://member.bilibili.com/platform/home',
})
export const lives = getSimpleLinkItem({
name: 'lives',
displayName: '直播',
href: 'https://live.bilibili.com',
})

View File

@ -1,9 +1,8 @@
.search-panel {
.search-panel, .search-pannel {
> .trending {
display: none !important;
}
&:not(:has(.history)) {
padding: 0 !important;
border: none !important;

View File

@ -9,6 +9,26 @@ const resetPlaceholder = (input: HTMLInputElement) => {
input.title = DEFAULT_PLACEHOLDER
}
const isInLiveRoomPage = () => {
return window.location.host === 'live.bilibili.com'
}
const getSearchInputClassName = () => {
return isInLiveRoomPage() ? 'input.nav-search-content' : 'input.nav-search-input'
}
const isSearchInput = (target: Node) => {
if (!(target instanceof HTMLInputElement)) {
return false
}
const isSearch = ['nav-search-content', 'nav-search-input'].some(n =>
target.classList.contains(n),
)
return target.placeholder !== DEFAULT_PLACEHOLDER && isSearch
}
export const component = defineComponentMetadata({
name: 'hideTrendingSearch',
displayName: '隐藏热搜',
@ -20,7 +40,8 @@ export const component = defineComponentMetadata({
},
],
entry: async () => {
const input: HTMLInputElement = await select('input.nav-search-input', {
const clsName = getSearchInputClassName()
const input: HTMLInputElement = await select(clsName, {
queryInterval: 500,
})
@ -32,12 +53,8 @@ export const component = defineComponentMetadata({
// Fallback to observer
allMutations(records => {
records.forEach(record => {
if (
record.target instanceof HTMLInputElement &&
record.target.classList.contains('nav-search-input') &&
record.target.placeholder !== DEFAULT_PLACEHOLDER
) {
resetPlaceholder(record.target)
if (isSearchInput(record.target)) {
resetPlaceholder(record.target as HTMLInputElement)
}
})
})

View File

@ -0,0 +1,5 @@
这个组件会将视频简介中的普通网址转换为可点击的链接,并将<del>被 Bilibili 抛弃</del>已失效的 `acg.tv` 跳转链接修复为 `nicovideo.jp` 链接。
例如: `https://acg.tv/sm37507315``https://www.nicovideo.jp/watch/sm37507315`
<i>本组件不会保证目标链接的安全性,因此在点击前请自行验证其是否可信</i>

View File

@ -0,0 +1,151 @@
import { defineComponentMetadata } from '@/components/define'
import { useScopedConsole } from '@/core/utils/log'
import { videoUrls } from '@/core/utils/urls'
const console = useScopedConsole('activeVideoLinks')
let observer: MutationObserver | null = null
const webRegex = /(?<!(\>|'|"|\/))(http:\/\/|https:\/\/|www\.)[^(\s,;())]+/g
const processAcgLinks = () => {
const links = document.querySelectorAll('a[href*="acg.tv"][href*="sm"]')
links.forEach(link => {
const originalHref = link.getAttribute('href')
if (originalHref) {
const newHref = originalHref.replace('acg.tv', 'nicovideo.jp/watch')
link.setAttribute('href', newHref)
console.log(`Niconico Fix: ${originalHref}${newHref}`)
}
})
}
const processDescLinks = () => {
const descContainer = document.querySelector('.desc-info-text')
if (!descContainer) {
return
}
const walker = document.createTreeWalker(descContainer, NodeFilter.SHOW_TEXT, {
acceptNode: node => {
if (node.parentElement?.closest('a')) {
return NodeFilter.FILTER_REJECT
}
return webRegex.test(node.textContent || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
},
})
const textNodes: Text[] = []
let currentNode: Node | null = walker.nextNode()
while (currentNode !== null) {
textNodes.push(currentNode as Text)
currentNode = walker.nextNode()
}
textNodes.forEach(textNode => {
const frag = document.createDocumentFragment()
const text = textNode.textContent || ''
let lastIndex = 0
let match: RegExpExecArray | null
webRegex.lastIndex = 0
match = webRegex.exec(text)
while (match !== null) {
const start = match.index
if (lastIndex < start) {
frag.appendChild(document.createTextNode(text.slice(lastIndex, start)))
}
const urlText = match[0]
const linkHref = urlText.replace(/^https?:\/\//, '//').replace(/^www\./, '//')
const a = document.createElement('a')
a.href = linkHref
a.target = '_blank'
a.textContent = urlText
frag.appendChild(a)
lastIndex = start + urlText.length
match = webRegex.exec(text)
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)))
}
textNode.parentNode?.replaceChild(frag, textNode)
})
}
const normalizeNicoDescLinks = () => {
const descContainer = document.querySelector('.desc-info-text')
if (!descContainer) {
return
}
const anchors = Array.from(descContainer.querySelectorAll('a'))
for (let i = 0; i < anchors.length - 1; i++) {
const a1 = anchors[i]
const a2 = anchors[i + 1]
const href1 = a1.getAttribute('href') || ''
const text2 = a2.textContent?.trim() || ''
const href2 = a2.getAttribute('href') || ''
// first link is base watch URL, second has sm ID text
if (
/^(https?:)?\/\/(www\.)?nicovideo\.jp\/watch\/?$/.test(href1) &&
/^sm\d+$/.test(text2) &&
href2.includes(`/watch/${text2}`)
) {
const newHref = href2.replace(/^(https?:)?/, '//')
const newA = document.createElement('a')
newA.href = newHref
newA.target = '_blank'
newA.textContent = text2
a1.replaceWith(newA)
a2.remove()
console.log(`Merged Nico link: ${text2}`)
// skip the next element
i++
}
}
}
const processLinks = () => {
try {
processAcgLinks()
processDescLinks()
normalizeNicoDescLinks()
} catch (error) {
console.error('处理链接时遇到 Error:', error)
}
}
const setupObservers = () => {
if (observer) {
observer.disconnect()
}
observer = new MutationObserver(processLinks)
observer.observe(document.documentElement, {
childList: true,
subtree: true,
})
console.log('已设置 Observer')
}
const entry = () => {
console.log('视频链接增强已启用')
processLinks()
setupObservers()
}
export const component = defineComponentMetadata({
name: 'activeVideoLinks',
displayName: '视频链接增强',
tags: [componentsTags.utils],
entry,
reload: entry,
unload: () => {
if (observer) {
observer.disconnect()
observer = null
}
},
urlInclude: videoUrls,
author: {
name: 'Alan Ye',
link: 'https://github.com/at-wr',
},
})

View File

@ -0,0 +1,64 @@
<template>
<DefaultWidget
:disabled="downloading"
name="下载up主表情包"
icon="mdi-emoticon-outline"
@click="download()"
></DefaultWidget>
</template>
<script lang="ts">
import { DefaultWidget } from '@/ui'
import { logError } from '@/core/utils/log'
import { Emoticons } from './emoticons'
import { DownloadPackage } from '@/core/download'
import { Toast } from '@/core/toast'
const downloader = new Emoticons()
export default Vue.extend({
components: {
DefaultWidget,
},
data() {
return {
downloading: false,
midRegex: /mid=(\d+)/,
roomIdRegex: /^https:\/\/live\.bilibili\.com\/(\d+)(?:\?.*)?$/,
}
},
methods: {
async download() {
this.downloading = true
try {
const url = window.location.href
if (url.startsWith('https://live.bilibili.com/')) {
const rid = this.roomIdRegex.exec(url)[1]
const emoticonsArray = await downloader.getEmoticonsArray(rid)
if (emoticonsArray.length === 0) {
Toast.info('该up主没有专属表情包', '表情包下载')
return
}
const downloadPromises = await downloader.downloadEmoticons(emoticonsArray)
const results = await downloader.batchDownload(downloadPromises)
const dpackage = new DownloadPackage()
dpackage.noEscape = true
for (const result of results) {
if (result !== null) {
for (const emoticon of result.emoticons) {
dpackage.add(`${result.pkg_name}/${emoticon.emoji}.png`, emoticon.blob)
}
}
}
await dpackage.emit(`emoticons-${rid}.zip`)
}
} catch (error) {
Toast.error('下载失败详情查看控制台', '表情包下载')
logError(error)
} finally {
this.downloading = false
}
},
},
})
</script>

View File

@ -0,0 +1,106 @@
import { monkey } from '@/core/ajax'
import { useScopedConsole } from '@/core/utils/log'
const logger = useScopedConsole('表情包下载')
interface DownloadPromise {
pkg_name: string // 表情包名称
emoticons: { emoji: string; blob: Promise<Blob> }[]
}
export class Emoticons {
private async getEmoticonsByRoomId(roomId: string) {
const response = await this.apiRequest(
`https://api.live.bilibili.com/xlive/web-ucenter/v2/emoticon/GetEmoticons?platform=pc&room_id=${roomId}`,
)
return response
}
async downloadEmoticons(emoticonsArray) {
const downloadPromises: DownloadPromise[] = emoticonsArray.map(obj => {
const emoticons = obj.emoticons.map(item => {
try {
const blob = this.getImageBlob(item.url)
return {
emoji: item.emoji,
blob,
}
} catch (error) {
logger.error(`下载表情 ${item.emoji} 失败:`, error)
return null
}
})
return {
emoticons,
pkg_name: obj.pkg_name,
}
})
return downloadPromises
}
async getEmoticonsArray(roomId: string): Promise<Array<any>> {
try {
let response = await this.getEmoticonsByRoomId(roomId)
if (response.data.data.length === 0) {
logger.log('获取表情包失败尝试获取真实roomId')
const realRoomId = await this.getRealRoomId(roomId)
response = await this.getEmoticonsByRoomId(realRoomId)
}
return response.data.data.slice(2)
} catch (error) {
throw new Error('获取表情包失败')
}
}
async batchDownload(tasks: DownloadPromise[]) {
const results: any[] = []
for (const task of tasks) {
const promise = (async () => {
const blobs: Blob[] = []
const blobPromises = task.emoticons.map(e => e.blob)
for (const blobPromise of blobPromises) {
const blob = await blobPromise
blobs.push(blob)
}
return {
pkg_name: task.pkg_name,
emoticons: task.emoticons.map((e, i) => ({ emoji: e.emoji, blob: blobs[i] })),
}
})()
results.push(promise)
}
return Promise.all(results)
}
// 有的直播间id是超短id需要获取到原始的roomid才能通过api获取到表情包
async getRealRoomId(rid: string): Promise<string> {
try {
const response = await this.apiRequest(
`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${rid}`,
)
if (Object.keys(response.data).length === 0) {
throw new Error('获取直播间id失败')
}
return response.data.room_id
} catch (error) {
throw new Error('获取直播间id失败')
}
}
apiRequest = (url: string): Promise<any> => {
const details: MonkeyXhrBasicDetails = {
url,
method: 'GET',
responseType: 'json',
}
return monkey(details)
}
getImageBlob = async (url: string) => {
const details: MonkeyXhrBasicDetails = {
url,
method: 'GET',
responseType: 'blob',
}
return monkey<Blob>(details)
}
}

View File

@ -0,0 +1,3 @@
## 下载up主专属表情包
支持下载up主专属表情包处于任意直播间页面时下载按钮会在`功能`面板显示,以压缩包形式保存。

View File

@ -0,0 +1,16 @@
import { defineComponentMetadata } from '@/components/define'
export const component = defineComponentMetadata({
name: 'downloadEmoticons',
displayName: '下载表情',
tags: [componentsTags.utils, componentsTags.live],
entry: none,
author: {
name: 'Pencilqaq',
link: 'https://github.com/pencilqaq',
},
widget: {
component: () => import('./Widget.vue').then(m => m.default),
},
urlInclude: [/^https:\/\/live\.bilibili\.com\/(\d+)/],
})

View File

@ -1,6 +1,9 @@
import { defineComponentMetadata } from '@/components/define'
import { useScopedConsole } from '@/core/utils/log'
import { bilibiliApi, getJsonWithCredentials } from '@/core/ajax'
import { select } from '@/core/spin-query'
import { allMutations } from '@/core/observer'
import { getUserInfo } from '@/core/user-info'
const displayName = '关注时间显示'
const console = useScopedConsole(displayName)
@ -22,20 +25,16 @@ type FollowTimeInfo = {
const midMap: Record<number, FollowTimeInfo> = {}
const insertFollowTimeToCard = (mid: number, dateStr: string, label: string) => {
const anchor = document.querySelector<HTMLAnchorElement>(
`${SELECTORS.relationCardAnchor}[href*="/${mid}"]`,
)
const anchor = dq(`${SELECTORS.relationCardAnchor}[href*="/${mid}"]`)
const card = anchor?.closest(SELECTORS.relationCardContainer)
if (!card) {
return
}
if (card.querySelector(`.${SELECTORS.relationCardTimeClass}`)) {
if (!card || card.querySelector(`.${SELECTORS.relationCardTimeClass}`)) {
return
}
const div = document.createElement('div')
div.className = SELECTORS.relationCardTimeClass
div.textContent = `${label}${dateStr}`
div.dataset.mid = mid.toString()
const sign = card.querySelector(SELECTORS.relationCardSign)
if (sign?.parentNode) {
@ -43,57 +42,37 @@ const insertFollowTimeToCard = (mid: number, dateStr: string, label: string) =>
}
}
let fetchWrapped = false
let pendingUpdate = false
const updateCards = () => {
if (pendingUpdate) {
return
}
pendingUpdate = true
requestAnimationFrame(() => {
pendingUpdate = false
const mids = Array.from(
document.querySelectorAll<HTMLAnchorElement>(SELECTORS.relationCardAnchor),
)
.map(el => el.href.match(/\/(\d+)/)?.[1])
.filter(Boolean)
.map(Number)
mids.forEach(mid => {
const data = midMap[mid]
if (data) {
const dateStr = new Date(data.mtime * 1000).toLocaleString()
insertFollowTimeToCard(mid, dateStr, data.label)
}
})
})
}
const waitForProfileContainer = () =>
new Promise<Element>(resolve => {
const check = () => document.querySelector(SELECTORS.profileFollowContainer)
let container = check()
if (container) {
resolve(container)
} else {
const observer = new MutationObserver(() => {
container = check()
if (container) {
observer.disconnect()
resolve(container)
const updateCards = (() => {
let scheduled = false
return () => {
if (scheduled) {
return
}
scheduled = true
requestAnimationFrame(() => {
scheduled = false
dqa(SELECTORS.relationCardAnchor).forEach(el => {
const match = (el as HTMLAnchorElement).href.match(/\/(\d+)/)
if (!match) {
return
}
const mid = Number(match[1])
if (Number.isNaN(mid)) {
return
}
const data = midMap[mid]
if (data) {
const dateStr = new Date(data.mtime * 1000).toLocaleString()
insertFollowTimeToCard(mid, dateStr, data.label)
}
})
observer.observe(document.body, { childList: true, subtree: true })
}
})
})
}
})()
const insertSubscribeTime = (followTimeStr: string) => {
const container = document.querySelector(SELECTORS.profileFollowContainer)
if (!container) {
return
}
if (container.querySelector(`.${SELECTORS.profileTextClass}`)) {
const container = dq(SELECTORS.profileFollowContainer)
if (!container || container.querySelector(`.${SELECTORS.profileTextClass}`)) {
return
}
@ -107,25 +86,22 @@ const insertSubscribeTime = (followTimeStr: string) => {
container.appendChild(infoEl)
}
const entry = async () => {
try {
const { addImportantStyle } = await import('@/core/style')
const { default: style } = await import('./subscribe-time.scss')
addImportantStyle(style, 'subscribe-time-style')
} catch (e) {
console.error('样式加载失败:', e)
}
if (!(unsafeWindow as any).subscribeTimeHooked) {
;(unsafeWindow as any).subscribeTimeHooked = true
const originalFetch = unsafeWindow.fetch
unsafeWindow.fetch = new Proxy(originalFetch, {
apply(target, thisArg, args) {
const url = typeof args[0] === 'string' ? args[0] : args[0].url
const urlObj = new URL(url, location.origin)
new MutationObserver(updateCards).observe(document.body, { childList: true, subtree: true })
if (!fetchWrapped) {
fetchWrapped = true
const originalFetch = unsafeWindow.fetch
unsafeWindow.fetch = new Proxy(originalFetch, {
apply(target, thisArg, args) {
const url = typeof args[0] === 'string' ? args[0] : args[0].url
if (url.includes('/x/relation/fans') || url.includes('/x/relation/followings')) {
return target.apply(thisArg, args).then(res => {
if (
urlObj.hostname === 'api.bilibili.com' &&
(urlObj.pathname.includes('/x/relation/fans') ||
urlObj.pathname.includes('/x/relation/followings'))
) {
return target
.apply(thisArg, args)
.then(res => {
res
.clone()
.json()
@ -137,54 +113,63 @@ const entry = async () => {
}
list.forEach(user => {
if (typeof user.mid === 'number' && typeof user.mtime === 'number') {
const label = url.includes('/x/relation/fans')
? 'Ta 关注你的时间'
: '你关注 Ta 的时间'
const label = url.includes('/fans') ? 'Ta 关注你的时间' : '你关注 Ta 的时间'
midMap[user.mid] = { mtime: user.mtime, label }
insertFollowTimeToCard(
user.mid,
new Date(user.mtime * 1000).toLocaleString(),
label,
)
const dateStr = new Date(user.mtime * 1000).toLocaleString()
insertFollowTimeToCard(user.mid, dateStr, label)
}
})
})
.catch(err => console.warn('JSON 解析失败:', err))
.catch(err => {
console.warn('JSON 解析失败:', err)
})
return res
})
}
return target.apply(thisArg, args)
},
})
.catch(err => {
console.warn('fetch 请求失败:', err)
throw err
})
}
return target.apply(thisArg, args)
},
})
}
const entry = async () => {
try {
const { addImportantStyle } = await import('@/core/style')
const { default: style } = await import('./subscribe-time.scss')
addImportantStyle(style, 'subscribe-time-style')
} catch (e) {
console.error('样式加载失败:', e)
}
allMutations(updateCards)
const userMid = (await getUserInfo()).mid
const match = location.href.match(/space\.bilibili\.com\/(\d+)/)
if (!match) {
console.warn('无法提取 mid')
return
}
const mid = Number(match[1])
if (mid === userMid) {
console.log('当前为本人空间,跳过关注时间显示')
return
}
const info = await bilibiliApi(
getJsonWithCredentials(`https://api.bilibili.com/x/web-interface/relation?mid=${mid}`),
)
if (!info?.relation) {
console.warn('未获取到 relation 信息')
return
}
if (!info.relation.mtime) {
console.log('当前未关注或关注时间无效')
const mtime = info?.relation?.mtime
if (!mtime) {
return
}
try {
await waitForProfileContainer()
} catch (e) {
console.warn('等待关注容器超时,跳过插入关注时间')
return
}
insertSubscribeTime(new Date(info.relation.mtime * 1000).toLocaleString())
await select(SELECTORS.profileFollowContainer)
insertSubscribeTime(new Date(mtime * 1000).toLocaleString())
}
export const component = defineComponentMetadata({
@ -200,5 +185,7 @@ export const component = defineComponentMetadata({
/https:\/\/space\.bilibili\.com\/\d+/,
],
entry,
description: { 'zh-CN': '在粉丝/关注列表及用户主页显示关注的具体时间。' },
description: {
'zh-CN': '在粉丝/关注列表及用户主页显示关注的具体时间。',
},
})

View File

@ -0,0 +1 @@
重定向**稍后再看页面**到普通视频页面,和`稍后再看重定向`组件的区别在于,该组件是在进入稍后再看的视频页面后进行重定向,而非直接替换链接到普通视频的链接,用于补充`稍后再看重定向`组件的功能。

View File

@ -0,0 +1,34 @@
import { ComponentEntry } from '@/components/types'
import { defineComponentMetadata } from '@/components/define'
const getBvidFromWatcherLaterVideoPage = () => {
const { pathname, search } = window.location
const oidAndBvid = search.match(/\?oid=(\d+)&bvid=(\w+)/) ?? []
if (pathname === '/list/watchlater' && oidAndBvid.length === 3) {
return oidAndBvid[2]
}
return null
}
const entry: ComponentEntry = async () => {
// 判断是否是稍后再看链接格式的视频页面,如果是则重定向
const bvid = getBvidFromWatcherLaterVideoPage()
if (bvid) {
location.href = `https://www.bilibili.com/video/${bvid}`
}
}
export const component = defineComponentMetadata({
name: 'watchlaterPageRedirect',
displayName: '稍后再看页面-重定向',
entry,
author: {
name: 'magicFeirl',
link: 'https://github.com/magicFeirl',
},
urlInclude: ['https://www.bilibili.com/list/watchlater'],
tags: [componentsTags.utils, componentsTags.video],
})

View File

@ -1,5 +1,5 @@
{
"version": "2.10.2",
"version": "2.10.3",
"author": "Grant Howard, Coulomb-G",
"copyright": "[year], Grant Howard (https://github.com/the1812) & Coulomb-G (https://github.com/Coulomb-G)",
"license": "MIT",

View File

@ -225,7 +225,7 @@ export class LiveSocket extends EventTarget {
if (
this.webSocket &&
[WebSocket.CONNECTING, WebSocket.OPEN].includes(this.webSocket.readyState)
([WebSocket.CONNECTING, WebSocket.OPEN] as number[]).includes(this.webSocket.readyState)
) {
this.stop()
}

View File

@ -194,32 +194,45 @@ export const responsiveGetPages = <T = any>(config: {
responsivePromise = new Promise(resolveResponsive => {
;(async () => {
const { api, getList, getTotal } = config
let page = 1
let total = Infinity
const result = []
while (result.length < total) {
const json = await api(page)
const result: T[] = []
const fetchPage = async (p: number): Promise<T[]> => {
const json = await api(p)
if (json.code !== 0) {
console.warn(
`api failed in ajax.getPages. message = ${json.message}, page = ${page}, total = ${total}, api = `,
`api failed in ajax.getPages. message = ${json.message}, page = ${p}, total = ${total}, api = `,
api,
)
return []
}
const list = getList(json)
result.push(...list)
if (page === 1) {
resolveResponsive(result)
}
return getList(json)
}
if (list.length === 0) {
resolveTotal(result)
break
}
page++
if (total === Infinity) {
total = getTotal(json)
// 请求第一次获得 total
const firstReq = await api(1)
result.push(...getList(firstReq))
total = getTotal(firstReq)
const pageSize = getList(firstReq).length || 1 // 防止为 0
const totalPages = Math.ceil(total / pageSize)
resolveResponsive(result) // 第一页
if (totalPages === 1) {
resolveTotal(result)
return
}
// 收集 Promise每 5 个为一个 batch 运行
const batchSize = 5
let batch: Promise<T[]>[] = []
for (let i = 2; i <= totalPages; i++) {
batch.push(fetchPage(i))
if (batch.length === batchSize || i === totalPages) {
const lists = await Promise.all(batch)
result.push(...lists.flat())
batch = []
}
}
// 等所有并发完成
resolveTotal(result)
})()
})
@ -242,6 +255,7 @@ export const getPages = async <T = any>(config: {
const result = await total
return result
}
/** bilibili API 标准响应 */
export interface BilibiliApiResponse {
code: number
@ -251,6 +265,7 @@ export interface BilibiliApiResponse {
data: any
result?: any
}
/**
* bilibili API
* @param apiPromise API Promise