Bilibili-Evolved/registry/lib/components/video/download/DownloadVideo.vue
2022-03-13 16:10:04 +08:00

445 lines
12 KiB
Vue

<template>
<VPopup
v-model="open"
class="download-video-panel"
:trigger-element="triggerElement"
>
<div class="download-video-panel-header">
<VIcon icon="mdi-download" />
<div class="title">
下载视频
</div>
<VButton type="transparent" title="关闭" @click="open = false">
<VIcon icon="mdi-close" :size="20" />
</VButton>
</div>
<div
v-if="selectedInput"
class="download-video-config-item"
>
<div class="download-video-config-title">
输入源:
</div>
<VDropdown
v-model="selectedInput"
:items="inputs"
/>
</div>
<div
v-if="inputs.length === 0"
class="download-video-config-item error"
>
没有匹配的输入源, 请确保安装了适合此页面的插件.
</div>
<component
:is="selectedInput.component"
v-if="selectedInput && selectedInput.component"
ref="inputOptions"
/>
<div
v-if="selectedApi"
class="download-video-config-item"
>
<div class="download-video-config-title">
格式:
</div>
<VDropdown
v-model="selectedApi"
:items="apis"
/>
</div>
<div
v-if="selectedApi && selectedApi.description"
class="download-video-config-description"
v-html="selectedApi.description"
>
</div>
<div
v-if="selectedQuality"
class="download-video-config-item"
>
<div class="download-video-config-title">
清晰度:
</div>
<VDropdown
v-model="selectedQuality"
:items="filteredQualities"
@change="saveSelectedQuality()"
/>
</div>
<template v-if="!testData.multiple && selectedQuality">
<div
v-if="testData.videoInfo"
class="download-video-config-description"
>
预计大小: {{ formatFileSize(testData.videoInfo.totalSize) }}
</div>
<div
v-if="testData.videoInfo === null"
class="download-video-config-description"
>
正在计算大小
</div>
</template>
<component
:is="a.component"
v-for="a of assetsWithOptions"
:key="a.name"
ref="assetsOptions"
:name="a.name"
/>
<div
v-if="selectedOutput"
class="download-video-config-item"
>
<div class="download-video-config-title">
输出方式:
</div>
<VDropdown
v-model="selectedOutput"
:items="outputs"
/>
</div>
<div
v-if="selectedOutput && selectedOutput.description"
class="download-video-config-description"
>
{{ selectedOutput.description }}
</div>
<component
:is="selectedOutput.component"
v-if="selectedOutput && selectedOutput.component"
ref="outputOptions"
/>
<div class="download-video-panel-footer">
<VButton
class="run-download"
type="primary"
:disabled="!canStartDownload"
@click="startDownload($refs.outputOptions, selectedOutput)"
>
开始
</VButton>
</div>
</VPopup>
</template>
<script lang="ts">
import { TestPattern } from '@/core/common-types'
import { getComponentSettings } from '@/core/settings'
import { matchUrlPattern } from '@/core/utils'
import { logError } from '@/core/utils/log'
import { formatFileSize } from '@/core/utils/formatters'
import {
VPopup, VButton, VDropdown, VIcon,
} from '@/ui'
import { registerAndGetData } from '@/plugins/data'
import { allQualities, VideoQuality } from '@/components/video/video-quality'
import { Toast } from '@/core/toast'
import { getFriendlyTitle } from '@/core/utils/title'
import { bangumiBatchInput } from './inputs/bangumi/batch'
import { videoBatchInput } from './inputs/video/batch'
import { videoSingleInput } from './inputs/video/input'
import {
videoDashAVC,
videoDashHEVC,
videoAudioDash,
} from './apis/dash'
import { videoFlv } from './apis/flv'
import { toastOutput } from './outputs/toast'
import {
DownloadVideoAction,
DownloadVideoApi,
DownloadVideoAssets,
DownloadVideoInput,
DownloadVideoInputItem,
DownloadVideoOutput,
} from './types'
const [inputs] = registerAndGetData(
'downloadVideo.inputs', [
videoSingleInput,
videoBatchInput,
bangumiBatchInput,
] as DownloadVideoInput[],
)
const [apis] = registerAndGetData(
'downloadVideo.apis', [
videoFlv,
videoDashAVC,
videoDashHEVC,
videoAudioDash,
] as DownloadVideoApi[],
)
const [assets] = registerAndGetData(
'downloadVideo.assets', [] as DownloadVideoAssets[],
)
const [outputs] = registerAndGetData(
'downloadVideo.outputs', [
toastOutput,
] as DownloadVideoOutput[],
)
const { basicConfig } = getComponentSettings('downloadVideo').options as {
basicConfig: {
api: string
quality: number
output: string
}
}
const filterData = <T extends { match?: TestPattern }> (items: T[]) => {
const matchedItems = items.filter(it => it.match?.some(p => matchUrlPattern(p)) ?? true)
return matchedItems
}
const getFallbackTestVideoInfo = () => ({
aid: unsafeWindow.aid,
cid: unsafeWindow.cid,
title: getFriendlyTitle(true),
} as DownloadVideoInputItem)
export default Vue.extend({
components: {
VPopup, VButton, VDropdown, VIcon,
},
props: {
triggerElement: {
required: true,
},
},
data() {
const lastOutput = basicConfig.output
return {
open: false,
busy: false,
// 测试 videoInfo, 用于获取清晰度列表和预计大小
testData: {
// undefined: 无法获取; null: 获取中;
videoInfo: null,
// 是否有多P
multiple: false,
},
assets,
qualities: [],
selectedQuality: undefined,
inputs: [],
selectedInput: undefined,
apis: [],
selectedApi: undefined,
outputs,
selectedOutput: outputs.find(it => it.name === lastOutput) || outputs[0],
}
},
computed: {
assetsWithOptions() {
return (this.assets as DownloadVideoAssets[]).filter(a => a.component)
},
filteredQualities() {
if (this.qualities.length === 0) {
return allQualities
}
return this.qualities
},
canStartDownload() {
if (this.busy || !this.open) {
return false
}
const isAnySelectionEmpty = Object.entries(this).filter(([key]) => key.startsWith('selected')).some(([, value]) => !value)
if (isAnySelectionEmpty) {
return false
}
return true
},
},
watch: {
selectedInput(input: DownloadVideoInput) {
if (input === undefined) {
return
}
this.updateTestVideoInfo()
},
selectedApi(api: DownloadVideoApi) {
if (api === undefined) {
return
}
this.updateTestVideoInfo()
basicConfig.api = api.name
},
selectedOutput(output: DownloadVideoOutput) {
if (output === undefined) {
return
}
basicConfig.output = output.name
},
},
mounted() {
coreApis.observer.videoChange(() => {
this.selectedInput = undefined
this.selectedApi = undefined
const matchedInputs = filterData(inputs)
this.inputs = matchedInputs
this.selectedInput = matchedInputs[0]
const matchedApis = filterData(apis)
this.apis = matchedApis
const lastApi = matchedApis.find(api => api.name === basicConfig.api)
if (lastApi) {
this.selectedApi = lastApi
} else {
this.selectedApi = matchedApis[0]
}
})
},
methods: {
formatFileSize,
// 只有手动选择的清晰度要记录, 因此不能直接 watch
saveSelectedQuality() {
const quality: VideoQuality = this.selectedQuality
if (quality === undefined) {
return
}
basicConfig.quality = quality.value
this.updateTestVideoInfo()
},
async getVideoItems() {
const input = this.selectedInput as DownloadVideoInput
const videoItems = await input.getInputs(this.$refs.inputOptions)
return videoItems
},
async updateTestVideoInfo() {
if (!this.selectedInput || !this.selectedApi) {
return
}
this.testData.videoInfo = null
const input = this.selectedInput as DownloadVideoInput
const testItem = input.getTestInput?.() ?? getFallbackTestVideoInfo()
console.log('[updateTestVideoInfo]', testItem)
this.testData.multiple = input.batch
const api = this.selectedApi as DownloadVideoApi
try {
const videoInfo = await api.downloadVideoInfo(testItem)
this.qualities = videoInfo.qualities
const isSelectedQualityOutdated = (
!this.selectedQuality
|| !(videoInfo.qualities.some(q => q.value === this.selectedQuality.value))
)
if (isSelectedQualityOutdated) {
this.selectedQuality = videoInfo.qualities[0]
if (basicConfig.quality) {
const [matchedQuality] = videoInfo.qualities.filter(q => q.value <= basicConfig.quality)
if (matchedQuality) {
this.selectedQuality = matchedQuality
}
}
}
// 填充 quality 后要再请求一次得到对应 quality 的统计数据
testItem.quality = this.selectedQuality
const qualityVideoInfo = await api.downloadVideoInfo(testItem)
this.testData.videoInfo = qualityVideoInfo
} catch (error) {
this.testData.videoInfo = undefined
}
},
async startDownload(instance: Vue, output: DownloadVideoOutput) {
try {
this.busy = true
const input = this.selectedInput as DownloadVideoInput
const api = this.selectedApi as DownloadVideoApi
const videoInputs = await input.getInputs(this.$refs.inputOptions)
if (videoInputs.length === 0) {
Toast.info('未接收到视频, 如果输入源支持批量, 请至少选择一个视频.', '下载视频', 3000)
return
}
videoInputs.forEach(item => {
item.quality = this.selectedQuality
})
const videoInfos = await Promise.all(videoInputs.map(i => api.downloadVideoInfo(i)))
if (videoInfos.length === 0 || lodash.sumBy(videoInfos, it => it.fragments.length) === 0) {
Toast.info('未接收到可下载数据, 请检查输入源和格式是否适用于当前视频.', '下载视频', 3000)
return
}
const action = new DownloadVideoAction(videoInfos)
const extraAssets = (await Promise.all(
assets.map(a => a.getAssets(
videoInfos,
this.$refs.assetsOptions.find((c: any) => c.$attrs.name === a.name),
)),
)).flat()
action.extraAssets.push(...extraAssets)
await action.downloadExtraAssets()
await output.runAction(action, instance)
} catch (error) {
logError(error)
} finally {
this.busy = false
}
},
},
})
</script>
<style lang="scss">
@import "common";
.download-video-panel {
font-size: 12px;
top: 100px;
left: 50%;
transform: translateX(-50%) scale(0.95);
transition: .2s ease-out;
z-index: 1000;
width: 320px;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 12px;
@include card();
&.open {
transform: translateX(-50%);
}
> :not(:first-child) {
margin-top: 12px;
}
.be-textbox,
.be-textarea {
flex-grow: 1;
}
&-header {
@include h-center();
align-self: stretch;
.title {
font-size: 16px;
font-weight: bold;
flex-grow: 1;
margin: 0 8px;
}
.be-button {
padding: 4px;
}
}
.download-video-config-item {
@include h-center();
.download-video-config-title {
margin-right: 8px;
}
&.error {
color: #E57373;
}
}
.download-video-config-section {
align-self: stretch;
}
.download-video-config-description {
opacity: .5;
margin-top: 4px;
}
&-footer {
align-self: stretch;
justify-content: center;
@include h-center();
}
.run-download {
font-size: 13px;
padding: 6px 12px;
}
}
</style>