Merge tag 'v2.9.0-preview' into preview-fixes

This commit is contained in:
the1812 2024-08-15 23:10:17 +08:00
commit 4d47a6fae4
25 changed files with 370 additions and 148 deletions

View File

@ -1,10 +1,10 @@
> 1%
last 2 versions
not dead
chrome > 100
edge > 100
firefox > 103
safari > 15
chrome > 105
edge > 105
firefox > 127
safari > 15.4
not and_chr > 0
not and_ff > 0
not and_qq > 0

View File

@ -74,7 +74,7 @@
## 最低配置
- 操作系统: Windows 8.1 / macOS Big Sur
- 分辨率: 1920x1080, 缩放 125%
- 浏览器: Chrome 105+ / Firefox 121+ / Safari 15.4+ (仅理论上能运行, 没测试过)
- 浏览器: Chrome 105+ / Firefox 127+ / Safari 15.4+ (仅理论上能运行, 没测试过)
- 处理器: Intel Core i5-10500 / AMD Ryzen 5 3600
- 内存: 8GB
- 脚本管理器: Tampermonkey 5.0 / Violentmonkey 2.18
@ -84,7 +84,7 @@
## 推荐配置
- 操作系统: Windows 10 / macOS Sonoma
- 分辨率: 3840x2160, 缩放 200%
- 浏览器: Chrome 120+ / Firefox 121+
- 浏览器: Chrome 120+ / Firefox 127+
- 处理器: Intel Core i7-11700 / AMD Ryzen 7 5800
- 内存: 32GB
- 脚本管理器: Tampermonkey 5.0 / Violentmonkey 2.18

View File

@ -0,0 +1,33 @@
import { defineComponentMetadata } from '@/components/define'
import { liveUrls } from '@/core/utils/urls'
const id = 'web-player-module-area-mask-panel'
const entry = async () => {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if ((node as Element).id === id) {
node.parentNode?.removeChild(node)
observer.disconnect()
}
})
})
})
observer.observe(document.body, { childList: true, subtree: true })
}
export const component = defineComponentMetadata({
name: 'removeLiveMaskPanel',
displayName: '删除直播马赛克遮罩',
author: {
name: 'Liki4',
link: 'https://github.com/Liki4',
},
tags: [componentsTags.live, componentsTags.style],
description: {
'zh-CN': '删除观看直播时某些分区的马赛克遮罩.',
},
entry,
reload: entry,
urlInclude: liveUrls,
})

View File

@ -103,7 +103,10 @@ const favoriteItemMapper = (item: any): FavoritesItemInfo => ({
title: item.title,
description: item.intro,
duration: item.duration,
durationText: formatDuration(item.duration),
durationText:
item.page > 1
? `${formatDuration(item.duration)} / ${item.page}P`
: formatDuration(item.duration),
playCount: item.cnt_info.play,
danmakuCount: item.cnt_info.danmaku,
upName: item.upper.name,

View File

@ -60,16 +60,6 @@
class="progress"
:style="{ width: h.progress * 100 + '%' }"
></div>
<div v-if="h.progressText" class="floating progress-number">
{{ h.progress >= 1 ? '已看完' : h.progressText }}
</div>
<div
v-if="h.liveStatus !== undefined"
class="floating duration live-status"
:class="{ on: h.liveStatus === 1 }"
>
{{ h.liveStatus === 1 ? '直播中' : '未开播' }}
</div>
<div v-if="h.durationText" class="floating duration">{{ h.durationText }}</div>
</a>
<a class="title" target="_blank" :href="h.url" :title="h.title">{{
@ -89,8 +79,25 @@
></DpiImage>
<div class="up-name">{{ h.upName }}</div>
</a>
<div v-if="h.timeText" class="time" :title="new Date(h.viewAt).toLocaleString()">
{{ h.timeText }}
<div class="history-info">
<div v-if="h.progressText" class="progress-number">
{{ h.progress >= 0.95 ? '已看完' : h.progressText }}
</div>
<div
v-if="h.liveStatus !== undefined"
class="duration live-status"
:class="{ on: h.liveStatus === 1 }"
>
{{ h.liveStatus === 1 ? '直播中' : '未开播' }}
</div>
<span
v-if="h.progressText || h.liveStatus !== undefined"
class="history-info-separator"
>|</span
>
<div v-if="h.timeText" class="time" :title="new Date(h.viewAt).toLocaleString()">
{{ h.timeText }}
</div>
</div>
</div>
</transition-group>
@ -405,22 +412,6 @@ export default Vue.extend({
filter: invert(0.9);
}
}
.duration {
left: $padding;
bottom: $padding;
padding: 0 6px;
}
.live-status {
&.on {
background-color: var(--theme-color);
color: var(--foreground-color);
}
}
.progress-number {
left: $padding;
top: $padding;
padding: 0 6px;
}
.progress {
position: absolute;
bottom: 0;
@ -448,7 +439,7 @@ export default Vue.extend({
}
}
.up,
.time {
.history-info {
font-size: 11px;
opacity: 0.75;
align-self: center;
@ -479,10 +470,23 @@ export default Vue.extend({
}
}
}
.time {
.history-info {
@include h-center(4px);
font-size: 11px;
grid-area: time;
padding-right: 6px;
&-separator {
margin: 0 4px;
}
}
.progress-number,
.live-status {
@include single-line();
}
.live-status {
&.on {
color: var(--theme-color);
}
}
}
}

View File

@ -1,5 +1,4 @@
import { getJsonWithCredentials, bilibiliApi } from '@/core/ajax'
import { fixed } from '@/core/utils'
import { formatDuration } from '@/core/utils/formatters'
/** 历史项目类型, 值为 API 中的 `history.business` */
@ -107,13 +106,13 @@ const getTimeData = () => {
}
}
const formatTime = (date: Date) => {
const { yesterday } = getTimeData()
const { yesterday, today } = getTimeData()
const timestamp = Number(date)
if (timestamp >= yesterday) {
return `${date.getHours().toString().padStart(2, '0')}:${date
.getMinutes()
return `${timestamp >= today ? '今天' : '昨天'} ${date
.getHours()
.toString()
.padStart(2, '0')}`
.padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
return `${(date.getMonth() + 1).toString().padStart(2, '0')}-${date
.getDate()
@ -154,7 +153,9 @@ const parseHistoryItem = (item: any): HistoryItem => {
cover,
covers: item.covers?.map(https) ?? [],
progress,
progressText: Number.isNaN(progress) ? null : `${fixed(progress * 100, 1)}%`,
progressText: Number.isNaN(progress)
? null
: `${formatDuration(item.progress)} / ${formatDuration(item.duration)}`,
duration: item.duration,
durationText: item.duration ? formatDuration(item.duration) : null,
upName: item.author_name,

View File

@ -34,18 +34,24 @@
<VIcon icon="mdi-close" :size="16"></VIcon>
</div>
<div class="floating duration">{{ card.durationText }}</div>
<div v-if="card.complete" class="floating viewed">已观看</div>
<div v-if="card.totalPages > 1" class="floating pages">
{{ card.currentPage }}P / {{ card.totalPages }}P
</div>
<div v-if="card.percent" class="progress" :style="{ width: card.percent * 100 + '%' }" />
</a>
<a class="title" target="_blank" :href="card.href" :title="card.title">{{ card.title }}</a>
<a
class="up"
target="_blank"
:href="'https://space.bilibili.com/' + card.upID"
:title="card.upName"
>
<DpiImage class="face" :src="card.upFaceUrl" :size="20"></DpiImage>
<div class="name">{{ card.upName }}</div>
</a>
<div class="info">
<a
class="up"
target="_blank"
:href="'https://space.bilibili.com/' + card.upID"
:title="card.upName"
>
<DpiImage class="face" :src="card.upFaceUrl" :size="20"></DpiImage>
<div class="name">{{ card.upName }}</div>
</a>
<div v-if="card.complete" class="viewed">已观看</div>
</div>
</div>
</transition-group>
</div>
@ -73,6 +79,9 @@ interface WatchlaterCard {
upName: string
upFaceUrl: string
upID: number
currentPage?: number
totalPages: number
percent: number
}
export default Vue.extend({
components: {
@ -125,28 +134,31 @@ export default Vue.extend({
return `https://www.bilibili.com/medialist/play/watchlater/${item.bvid}`
}
const cards = rawList.map(item => {
const currentPage = item.pages?.find(p => p.cid === item.cid)
const duration = currentPage?.duration ?? item.duration
const href = (() => {
if (item.pages === undefined || !this.redirect) {
if (!currentPage || !this.redirect) {
return getLink(item)
}
const pages = item.pages.map(it => it.cid)
const page = item.cid === 0 ? 1 : pages.indexOf(item.cid) + 1
const { page } = currentPage
return page <= 1 ? getLink(item) : `${getLink(item)}?p=${page}`
})()
const percent = Math.round((1000 * item.progress) / item.duration) / 1000
const percent = Math.round((1000 * item.progress) / duration) / 1000
return {
aid: item.aid,
href,
coverUrl: item.pic.replace('http:', 'https:'),
durationText: formatDuration(item.duration),
duration: item.duration,
// percent: `${fixed(percent * 100)}%`,
durationText: formatDuration(duration),
duration,
complete: item.progress < 0 || percent > 0.95, // 95%, -1100%
title: item.title,
upName: item.owner.name,
upFaceUrl: item.owner.face.replace('http:', 'https:'),
upID: item.owner.mid,
currentPage: currentPage?.page,
totalPages: item.videos,
percent,
} as WatchlaterCard
})
this.cards = cards
@ -291,6 +303,11 @@ export default Vue.extend({
border-radius: 8px 0 0 8px;
position: relative;
$padding: 6px;
.floating {
position: absolute;
opacity: 0;
font-size: 11px;
}
.remove {
top: $padding;
left: $padding;
@ -301,20 +318,22 @@ export default Vue.extend({
bottom: $padding;
padding: 0 6px;
}
.viewed {
white-space: nowrap;
right: $padding;
.pages {
top: $padding;
right: $padding;
padding: 0 6px;
}
.floating {
position: absolute;
opacity: 0;
font-size: 11px;
}
.cover {
object-fit: cover;
}
.progress {
position: absolute;
bottom: 0;
left: 0;
height: 2px;
border-radius: 1px;
background-color: var(--theme-color);
}
}
&:hover {
.floating {
@ -333,17 +352,21 @@ export default Vue.extend({
color: var(--theme-color) !important;
}
}
// .info {
// grid-area: info;
// display: flex;
// align-items: center;
// justify-content: space-around;
// margin: 8px;
// }
.info {
display: flex;
justify-content: space-between;
align-items: flex-end;
grid-area: info;
margin: 6px 8px;
.viewed {
opacity: 0.75;
font-size: 11px;
margin: 2px 0;
}
}
.up {
flex: 0 1 auto;
padding: 2px 10px 2px 2px;
margin: 0 8px 6px;
justify-self: start;
align-self: center;
max-width: calc(100% - 16px);

View File

@ -85,6 +85,10 @@ export const component = wrapSwitchOptions({
defaultValue: false,
displayName: '高能榜提示',
},
recommendedRooms: {
defaultValue: false,
displayName: '推荐直播间',
},
skin: {
defaultValue: false,
displayName: '房间皮肤',

View File

@ -27,7 +27,8 @@ $prefix: 'simplifyLiveroom-switch';
&-pk .chaos-pk,
&-pk .awesome-pk-box,
&-topRank .chat-item.top3-notice,
&-topRank .chat-item .rank-icon {
&-topRank .chat-item .rank-icon,
&-recommendedRooms .left-container .room-info-ctnr {
display: none !important;
}
}

View File

@ -13,7 +13,7 @@ import { getComponentSettings } from '@/core/settings'
import { Toast } from '@/core/toast'
import { retrieveImageUrl } from '@/core/utils'
import { logError } from '@/core/utils/log'
import { formatTitle } from '@/core/utils/title'
import { formatTitle, getTitleVariablesFromDate } from '@/core/utils/title'
import { DefaultWidget } from '@/ui'
export default Vue.extend({
@ -31,25 +31,45 @@ export default Vue.extend({
const toast = Toast.info('下载中...', '导出图片')
this.busy = true
try {
const publishDate = getTitleVariablesFromDate(
new Date(
// eslint-disable-next-line no-underscore-dangle
(unsafeWindow.__INITIAL_STATE__?.readInfo?.publish_time ?? 0) * 1000,
),
)
const variables = {
cv: document.URL.match(/read\/cv(\d+)/)?.at(1),
publishYear: publishDate.year,
publishMonth: publishDate.month,
publishDay: publishDate.day,
publishHour: publishDate.hour,
publishMinute: publishDate.minute,
publishSecond: publishDate.second,
publishMillisecond: publishDate.millisecond,
}
const images: { name: string; extension: string; url: string }[] = []
const bannerElement = dq('.banner-image .card-image__image') as HTMLDivElement
const bannerUrl = retrieveImageUrl(bannerElement)
if (bannerUrl) {
images.push({
...bannerUrl,
name: `${formatTitle(columnFormat, false, { n: '1' })}${bannerUrl.extension}`,
name: `${formatTitle(columnFormat, false, { n: '1', ...variables })}${
bannerUrl.extension
}`,
})
console.log(bannerElement, bannerUrl, images)
}
const articleImages = dqa('.article-content .img-box img:not([class*="cut-off-"])')
const articleImages = dqa('.article-content :is(img.normal-img, .normal-img img)')
articleImages.forEach((image: HTMLElement) => {
const url = retrieveImageUrl(image)
if (url) {
images.push({
...url,
name: `${formatTitle(columnFormat, false, { n: (images.length + 1).toString() })}${
url.extension
}`,
name: `${formatTitle(columnFormat, false, {
n: (images.length + 1).toString(),
...variables,
})}${url.extension}`,
})
}
})
@ -68,7 +88,8 @@ export default Vue.extend({
)
const pack = new DownloadPackage()
imageBlobs.forEach((blob, index) => pack.add(images[index].name, blob))
await pack.emit(`${formatTitle(columnFormat, false, { n: '' })}.zip`)
await pack.emit(`${formatTitle(columnFormat, false, { n: '', ...variables })}.zip`)
} catch (error) {
logError(error)
} finally {

View File

@ -3,8 +3,8 @@ import { ComponentEntry } from '@/components/types'
import { getBlob } from '@/core/ajax'
import { DownloadPackage } from '@/core/download'
import { Toast } from '@/core/toast'
import { matchUrlPattern, retrieveImageUrl } from '@/core/utils'
import { formatTitle } from '@/core/utils/title'
import { getVue2Data, matchUrlPattern, retrieveImageUrl } from '@/core/utils'
import { formatTitle, getTitleVariablesFromDate } from '@/core/utils/title'
import { feedsUrls } from '@/core/utils/urls'
import { Options } from '.'
@ -36,6 +36,38 @@ export const setupFeedImageExporter: ComponentEntry<Options> = async ({
}
const toast = Toast.info('下载中...', '导出图片')
let downloadedCount = 0
const vueData = getVue2Data(card.element)
const authorModule = lodash.get(vueData, 'data.modules.module_author', {})
const repostAuthorModule = lodash.get(vueData, 'data.orig.modules.module_author', {})
const date = getTitleVariablesFromDate(new Date(authorModule.pub_ts * 1000))
const repostDate = getTitleVariablesFromDate(
new Date((repostAuthorModule.pub_ts ?? authorModule.pub_ts) * 1000),
)
const variables = {
id: card.id,
user: card.username,
userID: authorModule.mid?.toString(),
originalUser: (card as RepostFeedsCard).repostUsername ?? card.username,
originalUserID: repostAuthorModule.mid?.toString() ?? authorModule.mid?.toString(),
originalID: lodash.get(vueData, 'data.orig.id_str', card.id),
publishYear: date.year,
publishMonth: date.month,
publishDay: date.day,
publishHour: date.hour,
publishMinute: date.minute,
publishSecond: date.second,
publishMillisecond: date.millisecond,
originalPublishYear: repostDate.year,
originalPublishMonth: repostDate.month,
originalPublishDay: repostDate.day,
originalPublishHour: repostDate.hour,
originalPublishMinute: repostDate.minute,
originalPublishSecond: repostDate.second,
originalPublishMillisecond: repostDate.millisecond,
}
const imageBlobs = await Promise.all(
imageUrls.map(async ({ url }) => {
const blob = await getBlob(url)
@ -48,10 +80,8 @@ export const setupFeedImageExporter: ComponentEntry<Options> = async ({
const { feedFormat } = options
imageBlobs.forEach((blob, index) => {
const titleData = {
user: card.username,
id: card.id,
originalUser: (card as RepostFeedsCard).repostUsername ?? card.username,
n: (index + 1).toString(),
...variables,
}
pack.add(
`${formatTitle(feedFormat, false, titleData)}${imageUrls[index].extension}`,
@ -60,10 +90,8 @@ export const setupFeedImageExporter: ComponentEntry<Options> = async ({
})
toast.close()
const packTitleData = {
user: card.username,
id: card.id,
originalUser: (card as RepostFeedsCard).repostUsername ?? card.username,
n: '',
...variables,
}
await pack.emit(`${formatTitle(feedFormat, false, packTitleData)}.zip`)
},

View File

@ -1,11 +1,40 @@
可以批量导出某个地方的图片, 目前支持动态和专栏.
动态文件名变量:
- `user`: 用户名
- `originalUser`: 被转发用户名, 如果不是转发类型的动态则等于 `user`
- `id`: 动态 ID
- `n`: 第 n 张图
- `id`: 动态 ID
- `user`: 用户名
- `userID`: 用户 ID
- 动态发布时间:
- `publishYear`
- `publishMonth`
- `publishDay`
- `publishHour`
- `publishMinute`
- `publishSecond`
- `publishMillisecond`
- 被转发的数据 (如果不是转发类型的动态, 则和上面的对应变量相同):
- `originalID`: 被转发的动态 ID
- `originalUser`: 被转发的用户名
- `originalUserID`: 被转发用户 ID
- 被转发的动态发布时间:
- `originalPublishYear`
- `originalPublishMonth`
- `originalPublishDay`
- `originalPublishHour`
- `originalPublishMinute`
- `originalPublishSecond`
- `originalPublishMillisecond`
专栏文件名变量:
- `n`: 第 n 张图
- `title`: 专栏标题
- `n`: 第 n 张图
- `cv`: 专栏 cv 号
- 专栏发布时间:
- `publishYear`
- `publishMonth`
- `publishDay`
- `publishHour`
- `publishMinute`
- `publishSecond`
- `publishMillisecond`

View File

@ -11,10 +11,12 @@ const options = defineOptionsMetadata({
columnFormat: {
defaultValue: '[title][ - n]',
displayName: '专栏图片命名格式',
multiline: true,
},
feedFormat: {
defaultValue: '[user][ - id][ - n]',
displayName: '动态图片命名格式',
multiline: true,
},
})

View File

@ -99,7 +99,6 @@ import { videoBatchInput, videoSeasonBatchInput } from './inputs/video/batch'
import { videoSingleInput } from './inputs/video/input'
import { videoDashAvc, videoDashHevc, videoDashAv1, videoAudioDash } from './apis/dash'
import { videoFlv } from './apis/flv'
import { toastOutput } from './outputs/toast'
import { streamSaverOutput } from './outputs/stream-saver'
import {
DownloadVideoAction,
@ -125,7 +124,6 @@ const [apis] = registerAndGetData('downloadVideo.apis', [
] as DownloadVideoApi[])
const [assets] = registerAndGetData('downloadVideo.assets', [] as DownloadVideoAssets[])
const [outputs] = registerAndGetData('downloadVideo.outputs', [
toastOutput,
streamSaverOutput,
] as DownloadVideoOutput[])
const { basicConfig } = getComponentSettings('downloadVideo').options as {

View File

@ -1,3 +1,15 @@
在功能面板中添加下载视频支持. 请注意:
- 不能下载超出账号权限的视频, 例如非大会员下载大会员清晰度视频, 或者大陆地区网络下载港澳台地区番剧, 都是不可以的.
- 请勿短时间进行大量下载, 以免遭到 b 站 IP 封禁.
在使用视频 (非番剧) 批量下载时, 文件的批量命名格式中可以使用以下额外变量:
- `user`: UP 主用户名
- `userID`: UP 主用户 ID
- 视频发布时间:
- `publishYear`
- `publishMonth`
- `publishDay`
- `publishHour`
- `publishMinute`
- `publishSecond`
- `publishMillisecond`

View File

@ -2,7 +2,7 @@ import { getJsonWithCredentials } from '@/core/ajax'
import { getGeneralSettings } from '@/core/settings'
import { formatDuration, formatNumber } from '@/core/utils/formatters'
import { logError } from '@/core/utils/log'
import { formatTitle } from '@/core/utils/title'
import { formatTitle, getTitleVariablesFromDate } from '@/core/utils/title'
import { videoUrls } from '@/core/utils/urls'
import { DownloadVideoInput } from '../../types'
import { createEpisodesPicker, EpisodeItem } from '../episode-item'
@ -22,11 +22,12 @@ export const videoBatchInput: DownloadVideoInput = {
logError(`获取视频选集列表失败, message = ${json.message}`)
return []
}
const { pages } = json.data
const { pages, owner, pubdate } = json.data
if (pages === undefined) {
logError('获取视频选集列表失败, 没有找到选集信息.')
return []
}
const date = getTitleVariablesFromDate(new Date(pubdate * 1000))
return pages.map((page: any, index: number) => {
const key = page.cid
const title = `P${page.page} ${page.part}`
@ -38,9 +39,18 @@ export const videoBatchInput: DownloadVideoInput = {
inputItem: {
allowQualityDrop: true,
title: formatTitle(getGeneralSettings().batchFilenameFormat, false, {
cid: page.cid,
n: formatNumber(page.page, pages.length),
cid: page.cid,
ep: page.part,
user: owner?.name,
userID: owner?.mid?.toString(),
publishYear: date.year,
publishMonth: date.month,
publishDay: date.day,
publishHour: date.hour,
publishMinute: date.minute,
publishSecond: date.second,
publishMillisecond: date.millisecond,
}),
cid: page.cid,
aid,
@ -64,6 +74,7 @@ export const videoSeasonBatchInput: DownloadVideoInput = {
logError(`获取视频合集列表失败, message = ${json.message}`)
return []
}
const owner = lodash.get(json, 'data.View.owner', {})
const sections: { episodes: any[] }[] = lodash.get(json, 'data.View.ugc_season.sections', [])
if (sections.length === 0) {
return []
@ -77,6 +88,7 @@ export const videoSeasonBatchInput: DownloadVideoInput = {
const key = episode.cid
const page = currentIndex + 1
const title = `P${page} ${episode.title}`
const date = getTitleVariablesFromDate(new Date(episode.arc.pubdate * 1000))
return {
key,
title,
@ -85,10 +97,19 @@ export const videoSeasonBatchInput: DownloadVideoInput = {
inputItem: {
allowQualityDrop: true,
title: formatTitle(getGeneralSettings().batchFilenameFormat, false, {
n: formatNumber(page, totalEpisodesLength),
title: episode.page.part,
cid: episode.cid,
n: formatNumber(page, totalEpisodesLength),
ep: episode.title,
user: owner.name,
userID: owner.mid?.toString(),
publishYear: date.year,
publishMonth: date.month,
publishDay: date.day,
publishHour: date.hour,
publishMinute: date.minute,
publishSecond: date.second,
publishMillisecond: date.millisecond,
}),
cid: episode.cid,
aid: episode.aid,

View File

@ -1,21 +0,0 @@
import { Toast } from '@/core/toast'
import { DownloadVideoOutput } from '../types'
export const toastOutput: DownloadVideoOutput = {
name: 'toast',
displayName: 'Toast',
description:
'弹一条消息显示出下载链接, 右键新标签页打开就可以下载. 链接有 referer 限制, 复制无用, 且不能保留视频文件名.',
runAction: async action => {
const fragments = action.infos.flatMap(it => it.titledFragments)
const urls = fragments.map(f => f.url).join('\n')
Toast.show(
fragments
.map(f => `<a class="link" href="${f.url}" download="${f.title}">${f.title}</a>`)
.join('\n'),
'下载视频',
)
console.log(urls)
console.log(action)
},
}

View File

@ -13,7 +13,10 @@
<button class="save" title="保存" @click="save">
<VIcon :size="28" icon="mdi-content-save-outline"></VIcon>
</button>
<button title="丢弃" class="discard" @click="discard">
<button class="copy" title="复制" @click="copy">
<VIcon :size="24" :icon="showCopyTip ? 'mdi-check' : 'mdi-content-copy'"></VIcon>
</button>
<button class="discard" title="丢弃" @click="discard">
<VIcon :size="28" icon="mdi-delete-forever-outline"></VIcon>
</button>
<span class="time">{{ time }}</span>
@ -23,29 +26,48 @@
</template>
<script lang="ts">
import { VIcon } from '@/ui'
import { Screenshot } from './screenshot'
export default Vue.extend({
components: {
VIcon,
},
props: {
objectUrl: {
type: String,
screenshot: {
type: Screenshot,
required: true,
},
filename: {
type: String,
required: true,
},
data() {
return {
showCopyTip: false,
}
},
computed: {
objectUrl() {
return this.screenshot.url
},
time: {
type: String,
required: true,
filename() {
return this.screenshot.filename
},
time() {
return this.screenshot.time
},
},
methods: {
discard() {
this.$emit('discard')
},
async copy() {
const screenshot = this.screenshot as Screenshot
await navigator.clipboard.write([
new ClipboardItem({ [screenshot.mimeType]: screenshot.blob }),
])
this.showCopyTip = true
setTimeout(() => {
this.showCopyTip = false
}, 1000)
},
save() {
const link = this.$refs.link as HTMLAnchorElement
link.addEventListener(

View File

@ -4,9 +4,7 @@
<VideoScreenshot
v-for="screenshot of screenshots"
:key="screenshot.id"
:filename="screenshot.filename"
:object-url="screenshot.url"
:time="screenshot.time"
:screenshot="screenshot"
@discard="discard(screenshot)"
></VideoScreenshot>
</transition-group>

View File

@ -3,6 +3,7 @@ import { getFriendlyTitle } from '@/core/utils/title'
const canvas = document.createElement('canvas')
export class Screenshot {
readonly mimeType = 'image/png'
url = ''
blob: Blob
timeStamp = new Date().getTime()
@ -57,7 +58,7 @@ export class Screenshot {
}
this.blob = blob
this.url = URL.createObjectURL(blob)
}, 'image/png')
}, this.mimeType)
} catch (error) {
logError(
'视频截图失败: 操作被浏览器阻止. 这通常发生于电影的试看片段, 请在正片尝试使用截图功能.',

View File

@ -11,6 +11,14 @@
:placeholder="value.toString()"
@change="type === 'text' ? valueChange($event) : numberChange($event)"
></TextBox>
<TextArea
v-if="type === 'textArea'"
change-on-blur
:validator="option.validator"
:text="value.toString()"
:placeholder="value.toString()"
@change="valueChange($event)"
></TextArea>
<SwitchBox v-if="type === 'boolean'" :checked="value" @change="valueChange($event)"></SwitchBox>
<ColorPicker
v-if="type === 'color'"
@ -54,7 +62,16 @@
</template>
<script lang="ts">
import { TextBox, SwitchBox, ColorPicker, RangeInput, VDropdown, ImagePicker, VSlider } from '@/ui'
import {
TextBox,
TextArea,
SwitchBox,
ColorPicker,
RangeInput,
VDropdown,
ImagePicker,
VSlider,
} from '@/ui'
import { getComponentSettings, ComponentSettings } from '@/core/settings'
import { OptionMetadata } from '../component'
import { getDropdownItems } from './dropdown'
@ -70,6 +87,7 @@ export default {
components: {
SwitchOptions,
TextBox,
TextArea,
SwitchBox,
ColorPicker,
RangeInput,
@ -123,6 +141,9 @@ export default {
if (option.dropdownEnum) {
return 'dropdown'
}
if (option.multiline) {
return 'textArea'
}
return 'text'
}
case 'object': {
@ -159,7 +180,7 @@ export default {
}
</script>
<style lang="scss" scoped>
<style lang="scss">
.component-option {
display: flex;
align-items: center;
@ -187,5 +208,9 @@ export default {
.be-slider {
margin: 0 8px;
}
textarea {
resize: vertical;
min-height: 16px;
}
}
</style>

View File

@ -45,10 +45,12 @@ const options = defineOptionsMetadata({
filenameFormat: {
defaultValue: '[title][ - ep]',
displayName: '文件命名格式',
multiline: true,
},
batchFilenameFormat: {
defaultValue: '[n - ][ep]',
displayName: '批量命名格式',
multiline: true,
},
downloadPackageEmitMode: {
defaultValue: DownloadPackageEmitMode.Packed,

View File

@ -59,6 +59,8 @@ export interface OptionMetadata<V = unknown> {
hidden?: boolean
/** 设为 `true` 时, 将用颜色选取器替代文本框 */
color?: boolean
/** 设为 `true` 时, 使用多行文本框 */
multiline?: boolean
/** 设置范围, 可以显示为一个滑动条 */
slider?: {
min?: number

View File

@ -31,12 +31,24 @@ const tokenSplit = (format: string) => {
}
return tokens.filter(it => it !== '')
}
export const getTitleVariablesFromDate = (date: Date, padStartLength = 2) => {
return {
year: date.getFullYear().toString(),
month: (date.getMonth() + 1).toString().padStart(padStartLength, '0'),
day: date.getDate().toString().padStart(padStartLength, '0'),
hour: date.getHours().toString().padStart(padStartLength, '0'),
minute: date.getMinutes().toString().padStart(padStartLength, '0'),
second: date.getSeconds().toString().padStart(padStartLength, '0'),
millisecond: date.getMilliseconds().toString().substring(0, 3),
}
}
export const formatTitle = (
format: string,
includesPageTitle = true,
extraVariables: StringMap = {},
) => {
const now = new Date()
const getLegacyTitle = () => {
return (
document.title
@ -55,6 +67,7 @@ export const formatTitle = (
.trim()
)
}
const dateVariables = getTitleVariablesFromDate(new Date())
const builtInVariables: StringMap = {
title: (() => {
const videoPageTitle = dq('.video-info-container .video-title')
@ -95,14 +108,13 @@ export const formatTitle = (
bvid: unsafeWindow.bvid,
cid: unsafeWindow.cid,
lid: document.URL.replace(/https:\/\/live\.bilibili\.com\/(blanc\/)?([\d]+)/, '$2'),
// 年月日这方法名真够乱的
y: now.getFullYear().toString(),
M: (now.getMonth() + 1).toString().padStart(2, '0'), // zero-based
d: now.getDate().toString().padStart(2, '0'),
h: now.getHours().toString().padStart(2, '0'),
m: now.getMinutes().toString().padStart(2, '0'),
s: now.getSeconds().toString().padStart(2, '0'),
ms: now.getMilliseconds().toString().substring(0, 3),
y: dateVariables.year,
M: dateVariables.month,
d: dateVariables.day,
h: dateVariables.hour,
m: dateVariables.minute,
s: dateVariables.second,
ms: dateVariables.millisecond,
}
const variables = {
...builtInVariables,

View File

@ -36,6 +36,7 @@ export default Vue.extend({
}
textarea {
resize: none;
field-sizing: content;
width: 0;
flex: 1 0 0;
padding: 4px 6px;