init: git clone https://github.com/iuroc/bilidown.git
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { ResJSON, timeoutController } from '../mixin'
|
||||
import { FavList, PlayInfo, SeasonInfo, TaskInitData, VideoInfo } from './type'
|
||||
|
||||
/**
|
||||
* 获取视频信息
|
||||
*
|
||||
* @throws {Error}
|
||||
*/
|
||||
export const getVideoInfo = async (bvid: string): Promise<VideoInfo> => {
|
||||
const { signal, timer } = timeoutController()
|
||||
|
||||
const res = await fetch(`/api/getVideoInfo?bvid=${bvid}`, {
|
||||
signal
|
||||
}).then(res => res.json()) as ResJSON<VideoInfo>
|
||||
if (!res.success) throw new Error(res.message)
|
||||
clearTimeout(timer)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剧集信息
|
||||
* @param epid EP 号
|
||||
* @param ssid SS 号
|
||||
* @throws {Error}
|
||||
*/
|
||||
export const getSeasonInfo = async (epid: number, ssid: number): Promise<SeasonInfo> => {
|
||||
const { signal, timer } = timeoutController()
|
||||
|
||||
const res = await fetch(`/api/getSeasonInfo?epid=${epid}&ssid=${ssid}`, {
|
||||
signal
|
||||
}).then(res => res.json()) as ResJSON<SeasonInfo>
|
||||
|
||||
if (!res.success) throw new Error(res.message)
|
||||
clearTimeout(timer)
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
||||
export const getPlayInfo = async (bvid: string, cid: number, controller: AbortController): Promise<PlayInfo> => {
|
||||
const res = await fetch(`/api/getPlayInfo?bvid=${bvid}&cid=${cid}`, {
|
||||
signal: controller.signal
|
||||
}).then(res => res.json()) as ResJSON<PlayInfo>
|
||||
if (!res.success) throw new Error(res.message)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export const createTask = async (tasks: TaskInitData[]): Promise<ResJSON> => {
|
||||
const { signal, timer } = timeoutController()
|
||||
|
||||
const res = await fetch('/api/createTask', {
|
||||
method: 'POST',
|
||||
signal,
|
||||
body: JSON.stringify(tasks),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(res => res.json()) as ResJSON
|
||||
|
||||
clearTimeout(timer)
|
||||
|
||||
if (!res.success) throw new Error(res.message)
|
||||
return res
|
||||
}
|
||||
|
||||
export const getPopularVideoBvIds = async (): Promise<string[]> => {
|
||||
const res = await fetch('/api/getPopularVideos').then(res => res.json()) as ResJSON<string[]>
|
||||
if (!res.success) throw new Error(res.message)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export const getRedirectedLocation = async (url: string): Promise<string> => {
|
||||
return fetch(`/api/getRedirectedLocation?url=${encodeURIComponent(url)}`)
|
||||
.then(res => res.json())
|
||||
.then((data: ResJSON<string>) => {
|
||||
if (!data.success) throw new Error(data.message)
|
||||
return data.data
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取收藏夹内视频列表 */
|
||||
export const getFavList = async (mediaId: number): Promise<FavList> => {
|
||||
return fetch(`/api/getFavList?mediaId=${mediaId}`)
|
||||
.then(res => res.json())
|
||||
.then((body: ResJSON<FavList>) => {
|
||||
if (!body.success) throw new Error(body.message)
|
||||
return body.data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import van, { State } from 'vanjs-core'
|
||||
import { Route, goto } from 'vanjs-router'
|
||||
import VideoInfoCard from './view/videoInfoCard'
|
||||
import { checkLogin, GLOBAL_HAS_LOGIN, showErrorPage } from '../mixin'
|
||||
import { VideoParseResult, VideoInfoCardMode, SectionItem } from './type'
|
||||
import { IDType, start } from './mixin'
|
||||
import { ParseModalComp } from './view/parseModal'
|
||||
import InputBox from './view/inputBox'
|
||||
import { Modal } from 'bootstrap'
|
||||
import { LoadingBox } from '../view'
|
||||
import { getPopularVideoBvIds } from './data'
|
||||
|
||||
const { div } = van.tags
|
||||
|
||||
export class WorkRoute {
|
||||
element: HTMLDivElement
|
||||
/** 输入框内容是否标识为异常 */
|
||||
urlInvalid = van.state(false)
|
||||
/** 仅作为类名字符串 */
|
||||
urlInvalidClass = van.derive(() => this.urlInvalid.val ? 'is-invalid' : '')
|
||||
urlValue = van.state('')
|
||||
videoInfoCardData = van.state<VideoParseResult>({
|
||||
title: '', description: '', cover: '', publishData: '', duration: 0,
|
||||
pages: [], owner: { face: '', mid: 0, name: '' },
|
||||
dimension: { width: 0, height: 0, rotate: 0 },
|
||||
staff: [], status: '', areas: [], styles: [], targetURL: '',
|
||||
section: []
|
||||
})
|
||||
/** 标识视频信息卡片应该显示普通视频还是剧集,值为 `hide` 时隐藏卡片 */
|
||||
videoInfoCardMode: VideoInfoCardMode = van.state('hide')
|
||||
ownerFaceHide = van.derive(() => this.videoInfoCardData.val.owner.face == '')
|
||||
|
||||
/** 全部选项卡和列表数据 */
|
||||
allSection
|
||||
/** 当前选项卡的按钮列表 */
|
||||
sectionPages
|
||||
/** 当前选中的按钮列表 */
|
||||
selectedPages
|
||||
/** 视频列表批量解析模态框 */
|
||||
parseModal: Modal
|
||||
|
||||
parseModalComp: ParseModalComp
|
||||
|
||||
/** 按钮是否处于 `loading` 状态,如果是则按钮设置为 `disabled` */
|
||||
btnLoading = van.state(false)
|
||||
|
||||
/** 页面初始加载的 loading 状态 */
|
||||
initLoading = van.state(true)
|
||||
|
||||
/** 是否是初始的热门推荐结果 */
|
||||
isInitPopular = van.state(false)
|
||||
|
||||
sectionTabsActiveIndex = van.state(0)
|
||||
|
||||
constructor() {
|
||||
const _that = this
|
||||
this.allSection = van.derive(() => {
|
||||
const list = (
|
||||
this.videoInfoCardMode.val == 'season'
|
||||
|| this.videoInfoCardMode.val == 'video' && this.videoInfoCardData.val.section.length == 0
|
||||
? [{ title: '正片', pages: this.videoInfoCardData.val.pages }] : []
|
||||
).concat(this.videoInfoCardData.val.section)
|
||||
return list
|
||||
})
|
||||
this.sectionPages = van.derive(() => {
|
||||
return this.allSection.val[this.sectionTabsActiveIndex.val]?.pages || []
|
||||
})
|
||||
this.selectedPages = van.derive(() => this.sectionPages.val.filter(page => page.selected.val))
|
||||
this.parseModalComp = new ParseModalComp({ workRoute: this })
|
||||
van.add(document.body, this.parseModalComp.element)
|
||||
|
||||
this.parseModal = new Modal(this.parseModalComp.element)
|
||||
|
||||
this.element = Route({
|
||||
rule: 'work',
|
||||
Loader() {
|
||||
return div(
|
||||
() => _that.initLoading.val ? LoadingBox() : '',
|
||||
div({ class: 'vstack gap-3', hidden: _that.initLoading },
|
||||
InputBox(_that),
|
||||
div({ hidden: () => _that.videoInfoCardMode.val == 'hide' || _that.btnLoading.val },
|
||||
VideoInfoCard(_that),
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
async onFirst() {
|
||||
if (!await checkLogin()) return
|
||||
let idType = this.args[0] as IDType
|
||||
let value = this.args[1]
|
||||
// if (!value) return goto('work'), _that.initLoading.val = false
|
||||
if (!value) {
|
||||
_that.isInitPopular.val = true
|
||||
const popularBvidList = await getPopularVideoBvIds()
|
||||
idType = 'bv'
|
||||
value = popularBvidList[Math.floor(Math.random() * popularBvidList.length)]
|
||||
}
|
||||
if (idType == 'bv' && !value.match(/^BV1[a-zA-Z0-9]+$/)) return goto('work')
|
||||
if ((idType == 'ep' || idType == 'ss' || idType == 'fav')
|
||||
&& !value.match(/^\d+$/)) return goto('work')
|
||||
if (idType == 'bv' && !_that.isInitPopular.val) _that.urlValue.val = value
|
||||
else if (idType == 'ep' || idType == 'ss') _that.urlValue.val = `${idType}${value}`
|
||||
start(_that, {
|
||||
idType,
|
||||
value,
|
||||
from: 'onFirst'
|
||||
}).catch(error => {
|
||||
const errorMessage = `获取视频信息失败:${error.message}`
|
||||
showErrorPage(errorMessage)
|
||||
_that.videoInfoCardMode.val = 'hide'
|
||||
}).finally(() => {
|
||||
_that.btnLoading.val = false
|
||||
_that.isInitPopular.val = false
|
||||
setTimeout(() => {
|
||||
_that.initLoading.val = false
|
||||
}, 200)
|
||||
})
|
||||
},
|
||||
async onLoad() {
|
||||
if (!GLOBAL_HAS_LOGIN.val) return goto('login')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default () => new WorkRoute().element
|
||||
@@ -0,0 +1,196 @@
|
||||
import { getFavList, getRedirectedLocation, getSeasonInfo, getVideoInfo } from './data'
|
||||
import { WorkRoute } from '.'
|
||||
import van from 'vanjs-core'
|
||||
import { Episode, PageInParseResult, VideoParseResult } from './type'
|
||||
import { ResJSON } from '../mixin'
|
||||
|
||||
/** 点击按钮开始解析 */
|
||||
export const start = async (
|
||||
workRoute: WorkRoute,
|
||||
option: {
|
||||
/** 标识字段类型 */
|
||||
idType: IDType
|
||||
/** 标识字段值 */
|
||||
value: string | number
|
||||
/** 调用来源 */
|
||||
from: 'click' | 'onFirst'
|
||||
}) => {
|
||||
// 如果初始载入路由时,路由参数不带有视频信息,则会随机选择一个热门视频
|
||||
// 这种情况展示的解析结果,不应该同步修改路由参数,用户直接刷新网页,可以继续随机推荐不同的视频
|
||||
// 而如果是单击按钮或者路由参数带有视频信息,那么实现的解析结果需要同步修改路由参数
|
||||
if (!workRoute.isInitPopular.val)
|
||||
history.replaceState(null, '', `#/work/${option.idType}/${option.value}`)
|
||||
workRoute.sectionTabsActiveIndex.val = 0
|
||||
if (option.idType === 'bv') {
|
||||
const bvid = option.value as string
|
||||
await getVideoInfo(bvid).then(info => {
|
||||
workRoute.videoInfoCardData.val = {
|
||||
section: (info.ugc_season.sections || []).map(i => {
|
||||
let index = 0
|
||||
return {
|
||||
title: (info.ugc_season.sections || []).length == 1 ? info.ugc_season.title : i.title,
|
||||
pages: i.episodes.flatMap(j => j.pages.map(k => {
|
||||
index++
|
||||
return {
|
||||
bvid: j.bvid,
|
||||
cid: k.cid,
|
||||
dimension: k.dimension,
|
||||
duration: k.duration,
|
||||
page: index,
|
||||
part: j.title,
|
||||
badge: index.toString(),
|
||||
selected: van.state(bvid == j.bvid)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
targetURL: `https://www.bilibili.com/video/${bvid}`,
|
||||
areas: [],
|
||||
styles: [],
|
||||
status: '',
|
||||
cover: info.pic,
|
||||
title: info.title,
|
||||
description: info.desc,
|
||||
publishData: new Date(info.pubdate * 1000).toLocaleString(),
|
||||
duration: info.duration,
|
||||
pages: info.pages.map((page, index) => ({
|
||||
...page,
|
||||
bvid,
|
||||
badge: (index + 1).toString(),
|
||||
selected: van.state(info.pages.length == 1)
|
||||
})),
|
||||
dimension: info.dimension,
|
||||
owner: info.owner,
|
||||
staff: info.staff?.map(i => `${i.name}[${i.title}]`) || []
|
||||
}
|
||||
workRoute.videoInfoCardMode.val = 'video'
|
||||
})
|
||||
} else if (option.idType === 'ep' || option.idType === 'ss') {
|
||||
const epid = option.idType === 'ep' ? option.value as number : 0
|
||||
const ssid = option.idType === 'ss' ? option.value as number : 0
|
||||
await getSeasonInfo(epid, ssid).then(info => {
|
||||
workRoute.videoInfoCardData.val = {
|
||||
section: (info.section || []).map(i => ({
|
||||
pages: i.episodes.map(episodeToPage),
|
||||
title: i.title
|
||||
})),
|
||||
targetURL: `https://www.bilibili.com/bangumi/play/${option.idType}${option.value}`,
|
||||
areas: info.areas.map(i => i.name),
|
||||
styles: info.styles,
|
||||
duration: 0,
|
||||
cover: info.cover,
|
||||
description: info.evaluate,
|
||||
owner: { name: info.actors, face: '', mid: 0 },
|
||||
pages: info.episodes.map(episodeToPage),
|
||||
status: info.new_ep.desc,
|
||||
publishData: new Date().toLocaleDateString(),
|
||||
staff: info.actors.split('\n'),
|
||||
dimension: { height: 0, rotate: 0, width: 0 },
|
||||
title: info.title
|
||||
}
|
||||
workRoute.videoInfoCardMode.val = 'season'
|
||||
})
|
||||
} else if (option.idType == 'fav') {
|
||||
const mediaId = option.value as number
|
||||
await getFavList(mediaId).then(favList => {
|
||||
workRoute.videoInfoCardData.val = {
|
||||
areas: [],
|
||||
cover: favList[0].cover,
|
||||
description: favList[0].intro,
|
||||
dimension: { height: 0, width: 0, rotate: 0 },
|
||||
duration: favList[0].duration,
|
||||
owner: favList[0].upper,
|
||||
pages: favList.map((info, index) => ({
|
||||
badge: (index + 1).toString(),
|
||||
selected: van.state(index == 0),
|
||||
bvid: info.bvid,
|
||||
cid: info.ugc.first_cid,
|
||||
dimension: { height: 0, width: 0, rotate: 0 },
|
||||
duration: info.duration,
|
||||
page: index,
|
||||
part: info.title,
|
||||
})),
|
||||
publishData: new Date(favList[0].pubtime * 1000).toLocaleString(),
|
||||
section: [],
|
||||
staff: [],
|
||||
status: '',
|
||||
styles: [],
|
||||
targetURL: `https://www.bilibili.com/video/${favList[0].bvid}`,
|
||||
title: favList[0].title
|
||||
}
|
||||
workRoute.videoInfoCardMode.val = 'video'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const episodeToPage = (episode: Episode, index: number): PageInParseResult => {
|
||||
return {
|
||||
bvid: episode.bvid,
|
||||
cid: episode.cid,
|
||||
dimension: episode.dimension,
|
||||
duration: episode.duration,
|
||||
page: index + 1,
|
||||
part: episode.long_title || (episode.title.match(/^\d+$/) ? `第 ${episode.title} 集` : episode.title),
|
||||
badge: episode.title,
|
||||
selected: van.state(false)
|
||||
}
|
||||
}
|
||||
|
||||
export type IDType = 'bv' | 'ep' | 'ss' | 'fav'
|
||||
|
||||
/**
|
||||
* 校验用户输入的待解析的视频链接
|
||||
* @param url 待解析的视频链接
|
||||
* @returns 如果校验成功,则返回 BV 号,否则返回 `false`
|
||||
*/
|
||||
export const checkURL = (url: string): {
|
||||
type: IDType
|
||||
value: string | number
|
||||
} => {
|
||||
const matchBvid = url.match(/^(?:https?:\/\/www\.bilibili\.com\/video\/)?(BV1[a-zA-Z0-9]+)/)
|
||||
if (matchBvid) return { type: 'bv', value: matchBvid[1] }
|
||||
|
||||
const matchSeason = url.match(/^(?:https?:\/\/www\.bilibili\.com\/bangumi\/play\/)?(ep|ss)(\d+)/)
|
||||
if (matchSeason) return { type: matchSeason[1] as 'ep' | 'ss', value: parseInt(matchSeason[2]) }
|
||||
|
||||
try {
|
||||
const _url = new URL(url)
|
||||
const mediaId = parseInt(_url.searchParams.get('fid') || '')
|
||||
if (_url.hostname == 'space.bilibili.com' && _url.pathname.match(/^\/\d+\/favlist$/) && !isNaN(mediaId)) {
|
||||
return { type: 'fav', value: mediaId }
|
||||
}
|
||||
const mlMatch = url.match(/^https:\/\/www.bilibili.com\/medialist\/detail\/ml(\d+)/)
|
||||
if (mlMatch) return { type: 'fav', value: mlMatch[1] }
|
||||
} catch { }
|
||||
throw new Error('您输入的视频链接格式错误')
|
||||
}
|
||||
|
||||
/** 将秒数转换为 `mm:ss` */
|
||||
export const secondToTime = (second: number) => {
|
||||
return `${Math.floor(second / 60)}:${(second % 60).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 如果是 B23 地址,则返回重定向后的地址,否则返回 `false` */
|
||||
export const handleB23 = async (url: string): Promise<string | false> => {
|
||||
if (!url.match(/^https:\/\/b23.tv\//)) return false
|
||||
const epMatch = url.match(/^https:\/\/b23.tv\/(ep|ss)(\d+)/)
|
||||
if (epMatch) return `https://www.bilibili.com/bangumi/play/${epMatch[1]}${epMatch[2]}`
|
||||
const location = await getRedirectedLocation(url)
|
||||
return location
|
||||
}
|
||||
|
||||
export const handleSeasonsArchivesList = async (url: string): Promise<string | false> => {
|
||||
try { new URL(url) } catch { return false }
|
||||
const _url = new URL(url)
|
||||
const mid = _url.pathname.match(/^\/(\d+)\/channel\/collectiondetail$/)?.[1]
|
||||
const seasonId = parseInt(_url.searchParams.get('sid') || '')
|
||||
if (_url.hostname == 'space.bilibili.com' && mid && !isNaN(seasonId)) {
|
||||
return fetch(`/api/getSeasonsArchivesListFirstBvid?mid=${mid}&seasonId=${seasonId}`)
|
||||
.then(res => res.json())
|
||||
.then((body: ResJSON<string>) => {
|
||||
if (!body.success) throw new Error(body.message)
|
||||
return body.data
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { State } from 'vanjs-core'
|
||||
|
||||
/**
|
||||
* 参考:[qn 视频清晰度标识](https://socialsisteryi.github.io/bilibili-API-collect/docs/video/videostream_url.html#qn%E8%A7%86%E9%A2%91%E6%B8%85%E6%99%B0%E5%BA%A6%E6%A0%87%E8%AF%86)
|
||||
*
|
||||
* | 值 | 含义 |
|
||||
* | --- | ----------: |
|
||||
* | 6 | 240P 极速 |
|
||||
* | 16 | 360P 流畅 |
|
||||
* | 32 | 480P 清晰 |
|
||||
* | 64 | 720P 高清 |
|
||||
* | 74 | 720P60 高帧率 |
|
||||
* | 80 | 1080P 高清 |
|
||||
* | 112 | 1080P+ 高码率 |
|
||||
* | 116 | 1080P60 高帧率 |
|
||||
* | 120 | 4K 超清 |
|
||||
* | 125 | HDR 真彩色 |
|
||||
* | 126 | 杜比视界 |
|
||||
* | 127 | 8K 超高清 |
|
||||
*/
|
||||
export type VideoFormat = 6 | 16 | 32 | 64 | 74 | 80 | 112 | 116 | 120 | 125 | 126 | 127
|
||||
|
||||
/** 视频解析结果,数据用于 DOM 渲染,同时兼容 BV、EP、SS */
|
||||
export type VideoParseResult = {
|
||||
/** 合集标题 */
|
||||
title: string
|
||||
/** 合集描述 */
|
||||
description: string
|
||||
/** 合集发布时间 */
|
||||
publishData: string
|
||||
/** 合集封面 */
|
||||
cover: string
|
||||
/** 合集总时长 */
|
||||
duration: number
|
||||
/** 分集列表 */
|
||||
pages: PageInParseResult[]
|
||||
|
||||
section: SectionItem[]
|
||||
|
||||
/** 合集作者 */
|
||||
owner: {
|
||||
mid: number
|
||||
name: string
|
||||
face: string
|
||||
}
|
||||
|
||||
/** 合集分辨率 */
|
||||
dimension: {
|
||||
width: number
|
||||
height: number
|
||||
rotate: number
|
||||
}
|
||||
staff: string[]
|
||||
/** 更新状态 */
|
||||
status: string
|
||||
/** 地区信息 */
|
||||
areas: string[]
|
||||
/** 分类标签 */
|
||||
styles: string[]
|
||||
/** 播放页面 */
|
||||
targetURL: string
|
||||
}
|
||||
|
||||
export type SectionItem = {
|
||||
title: string
|
||||
pages: PageInParseResult[]
|
||||
}
|
||||
|
||||
export type PageInParseResult = {
|
||||
/** 分集 CID */
|
||||
cid: number
|
||||
/** 分集 BVID */
|
||||
bvid: string
|
||||
/** 分集在合集中的序号,从 1 开始 */
|
||||
page: number
|
||||
/** 分集标题 */
|
||||
part: string
|
||||
/** 分集时长 */
|
||||
duration: number
|
||||
/** 分集分辨率 */
|
||||
dimension: {
|
||||
width: number
|
||||
height: number
|
||||
rotate: number
|
||||
}
|
||||
/** 前置胶囊标签内容 */
|
||||
badge: string
|
||||
/** 是否选中 */
|
||||
selected: State<boolean>
|
||||
}
|
||||
|
||||
export type StaffItem = {
|
||||
mid: number
|
||||
title: string
|
||||
name: string
|
||||
face: string
|
||||
}
|
||||
|
||||
type PageInVideoInfo = {
|
||||
cid: number
|
||||
page: number
|
||||
from: string
|
||||
part: string
|
||||
duration: number
|
||||
dimension: {
|
||||
width: number
|
||||
height: number
|
||||
rotate: number
|
||||
}
|
||||
}
|
||||
|
||||
/** 接口返回的视频信息 */
|
||||
export type VideoInfo = {
|
||||
aid: number
|
||||
staff: null | StaffItem[]
|
||||
title: string
|
||||
desc: string
|
||||
pubdate: number
|
||||
pic: string
|
||||
duration: number
|
||||
bvid: string
|
||||
pages: PageInVideoInfo[]
|
||||
owner: {
|
||||
mid: number
|
||||
name: string
|
||||
face: string
|
||||
}
|
||||
dimension: {
|
||||
width: number
|
||||
height: number
|
||||
rotate: number
|
||||
},
|
||||
ugc_season: {
|
||||
sections: {
|
||||
title: string
|
||||
episodes: {
|
||||
title: string
|
||||
pages: PageInVideoInfo[]
|
||||
bvid: string
|
||||
}[]
|
||||
}[] | null
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 接口返回的剧集信息 */
|
||||
export type SeasonInfo = {
|
||||
actors: string
|
||||
areas: {
|
||||
id: number
|
||||
name: string
|
||||
}[]
|
||||
cover: string
|
||||
evaluate: string
|
||||
publish: {
|
||||
is_finish: number
|
||||
pub_time: string
|
||||
};
|
||||
season_id: number
|
||||
season_title: string
|
||||
stat: {
|
||||
coins: number
|
||||
danmakus: number
|
||||
favorite: number
|
||||
favorites: number
|
||||
likes: number
|
||||
reply: number
|
||||
share: number
|
||||
views: number
|
||||
}
|
||||
styles: string[]
|
||||
title: string
|
||||
total: number
|
||||
episodes: Episode[]
|
||||
new_ep: {
|
||||
desc: string
|
||||
is_new: number
|
||||
}
|
||||
section: {
|
||||
title: string
|
||||
episodes: Episode[]
|
||||
}[] | null
|
||||
}
|
||||
|
||||
export type Episode = {
|
||||
aid: number
|
||||
bvid: string
|
||||
cid: number
|
||||
cover: string
|
||||
dimension: {
|
||||
width: number
|
||||
height: number
|
||||
rotate: number
|
||||
}
|
||||
duration: number
|
||||
ep_id: number
|
||||
long_title: string
|
||||
pub_time: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export type VideoInfoCardMode = State<"video" | "season" | "hide">
|
||||
|
||||
export type Media<Type = 'video' | 'audio'> = {
|
||||
id: Type extends 'video' ? VideoFormat : number
|
||||
baseUrl: string
|
||||
backupUrl: string[]
|
||||
bandwidth: number
|
||||
mimeType: string
|
||||
codecs: string
|
||||
width: number
|
||||
height: number
|
||||
frameRate: string
|
||||
codecid: number
|
||||
}
|
||||
|
||||
export type PlayInfo = {
|
||||
// accept_description: string[]
|
||||
accept_quality: VideoFormat[]
|
||||
// support_formats: {
|
||||
// quality: number
|
||||
// format: string
|
||||
// new_description: string
|
||||
// codecs: string[]
|
||||
// }[]
|
||||
dash: {
|
||||
duration: number
|
||||
video: Media<'video'>[]
|
||||
audio: Media<'audio'>[]
|
||||
flac: {
|
||||
audio: Media
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建任务时的初始数据 */
|
||||
export type TaskInitData = {
|
||||
bvid: string
|
||||
cid: number
|
||||
format: number
|
||||
title: string
|
||||
owner: string
|
||||
cover: string
|
||||
audio: string
|
||||
video: string
|
||||
duration: number
|
||||
downloadType: 'audio' | 'video' | 'merge'
|
||||
}
|
||||
|
||||
/** 任务数据库中的数据 */
|
||||
export type TaskInDB = TaskInitData & {
|
||||
id: number
|
||||
folder: string
|
||||
createAt: string
|
||||
status: TaskStatus
|
||||
}
|
||||
|
||||
export type TaskStatus = 'done' | 'waiting' | 'running' | 'error'
|
||||
|
||||
export type FavList = FavListItem[]
|
||||
|
||||
export type FavListItem = {
|
||||
title: string
|
||||
cover: string
|
||||
intro: string
|
||||
duration: number
|
||||
upper: {
|
||||
mid: number
|
||||
name: string
|
||||
face: string
|
||||
}
|
||||
pubtime: number
|
||||
bvid: string
|
||||
ugc: {
|
||||
first_cid: number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import van from 'vanjs-core'
|
||||
import { goto } from 'vanjs-router'
|
||||
import { v4 } from 'uuid'
|
||||
import { checkURL, handleB23, handleSeasonsArchivesList, start } from '../mixin'
|
||||
import { WorkRoute } from '..'
|
||||
import { VanComponent } from '../../mixin'
|
||||
|
||||
const { button, div, input, label, span } = van.tags
|
||||
|
||||
class InputBoxComp implements VanComponent {
|
||||
element: HTMLElement
|
||||
btnID = v4()
|
||||
|
||||
constructor(public workRoute: WorkRoute) {
|
||||
this.element = div(
|
||||
div({ class: () => `hstack gap-3 align-items-stretch ${workRoute.urlInvalidClass.val}` },
|
||||
div({ class: () => `form-floating flex-fill` },
|
||||
input({
|
||||
class: () => `form-control border-3 ${workRoute.urlInvalidClass.val}`,
|
||||
placeholder: '请输入待解析的视频链接',
|
||||
value: workRoute.urlValue,
|
||||
oninput: event => workRoute.urlValue.val = event.target.value,
|
||||
onkeyup: event => {
|
||||
if (event.key === 'Enter') document.getElementById(this.btnID)?.click()
|
||||
}
|
||||
}),
|
||||
label({ class: 'w-100' }, '请输入视频链接或 BV/EP/SS 号')
|
||||
),
|
||||
ParseButton(this, false, this.btnID),
|
||||
ParseButton(this, true)
|
||||
),
|
||||
div({ class: 'invalid-feedback' }, () => workRoute.urlInvalid.val ? '您输入的视频链接格式错误' : ''),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ParseButton = (parent: InputBoxComp, large: boolean, id: string = '') => {
|
||||
const { workRoute } = parent
|
||||
|
||||
return button({
|
||||
class: `btn btn-success text-nowrap ${large ? `btn-lg d-none d-md-block` : 'd-md-none'}`,
|
||||
async onclick() {
|
||||
try {
|
||||
workRoute.btnLoading.val = true
|
||||
workRoute.urlValue.val = workRoute.urlValue.val.trim()
|
||||
try {
|
||||
const handleB23Result = await handleB23(workRoute.urlValue.val)
|
||||
if (handleB23Result) workRoute.urlValue.val = handleB23Result
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return alert(error.message)
|
||||
}
|
||||
try {
|
||||
const handleSeasonsArchivesListResult = await handleSeasonsArchivesList(workRoute.urlValue.val)
|
||||
if (handleSeasonsArchivesListResult) workRoute.urlValue.val = handleSeasonsArchivesListResult
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return alert(error.message)
|
||||
}
|
||||
const { type, value } = checkURL(workRoute.urlValue.val)
|
||||
workRoute.urlInvalid.val = false
|
||||
await start(workRoute, {
|
||||
idType: type,
|
||||
value,
|
||||
from: 'click'
|
||||
}).catch(error => {
|
||||
const errorMessage = `获取视频信息失败:${error.message}`
|
||||
alert(errorMessage)
|
||||
goto('work')
|
||||
workRoute.videoInfoCardMode.val = 'hide'
|
||||
})
|
||||
} catch (error) {
|
||||
workRoute.urlInvalid.val = true
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
workRoute.btnLoading.val = false
|
||||
}, 200)
|
||||
}
|
||||
},
|
||||
id,
|
||||
disabled: workRoute.btnLoading
|
||||
}, span({ class: 'spinner-border spinner-border-sm me-2', hidden: () => !workRoute.btnLoading.val }),
|
||||
() => workRoute.btnLoading.val ? '解析中' : '解析视频'
|
||||
)
|
||||
}
|
||||
|
||||
export default (workRoute: WorkRoute) => new InputBoxComp(workRoute).element
|
||||
@@ -0,0 +1,337 @@
|
||||
import van, { State } from 'vanjs-core'
|
||||
import { VanComponent, formatSeconds } from '../../mixin'
|
||||
import { PageInParseResult, PlayInfo, VideoFormat } from '../type'
|
||||
import { WorkRoute } from '..'
|
||||
import { createTask, getPlayInfo } from '../data'
|
||||
import PQueue from 'p-queue'
|
||||
|
||||
const { a, button, div, input, select, option, label } = van.tags
|
||||
|
||||
type Option = {
|
||||
workRoute: WorkRoute
|
||||
}
|
||||
|
||||
const videoFormatMap: Record<VideoFormat, string> = {
|
||||
127: "超高清 8K",
|
||||
126: "杜比视界",
|
||||
125: "真彩 HDR",
|
||||
120: "超清 4K",
|
||||
116: "高清 1080P60",
|
||||
112: "高清 1080P+",
|
||||
80: "高清 1080P",
|
||||
74: "高清 720P60",
|
||||
64: "高清 720P",
|
||||
32: "清晰 480P",
|
||||
16: "流畅 360P",
|
||||
6: "极速 240P",
|
||||
}
|
||||
|
||||
const codecMap: Record<12 | 7 | 13, string> = {
|
||||
12: "HEVC (hev1)",
|
||||
7: "AVC (avc1)",
|
||||
13: "AV1 (av01)",
|
||||
}
|
||||
|
||||
export class ParseModalComp implements VanComponent {
|
||||
element: HTMLElement
|
||||
|
||||
totalCount: State<number>
|
||||
finishCount = van.state(0)
|
||||
|
||||
abortControllers: AbortController[] = []
|
||||
|
||||
currentController?: AbortController
|
||||
|
||||
allPlayInfo: State<{
|
||||
page: PageInParseResult
|
||||
info: PlayInfo | null
|
||||
selected: State<boolean>
|
||||
formatIndex: State<number>
|
||||
}[]> = van.state([])
|
||||
|
||||
/** 该属性用于在点击“开始下载”按钮后使按钮变为禁用状态,防止多次点击 */
|
||||
downloadBtnDisabled = van.state(false)
|
||||
|
||||
/** 下载类型:audio 仅音频,video 仅视频,merge 音视频合并 */
|
||||
downloadType = van.state<'audio' | 'video' | 'merge'>('merge')
|
||||
|
||||
/** 优先视频编码格式:12 hev1, 7 avc1, 13 av01 */
|
||||
preferredCodec = van.state<12 | 7 | 13>(12)
|
||||
/** 是否优先使用HiRes(flac)音频 */
|
||||
preferHiResAudio = van.state(true)
|
||||
|
||||
errorList: State<string[]> = van.state([])
|
||||
|
||||
constructor(public option: Option) {
|
||||
this.totalCount = van.derive(() => option.workRoute.selectedPages.val.length)
|
||||
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
|
||||
this.element = div({ class: `modal fade`, tabIndex: -1 },
|
||||
div({ class: () => `modal-dialog modal-xl modal-fullscreen-xl-down ${(this.totalCount.val + this.errorList.val.length) < 10 ? '' : 'modal-dialog-scrollable'}` },
|
||||
div({ class: `modal-content` },
|
||||
div({ class: `modal-header` },
|
||||
div({ class: `h5 modal-title` }, () => allFinish.val ? '批量下载' : '批量解析'),
|
||||
button({ class: `btn-close`, 'data-bs-dismiss': `modal` })
|
||||
),
|
||||
div({ class: `modal-body vstack gap-3`, tabIndex: -1, style: 'outline: none;' },
|
||||
this.ParseProgress(),
|
||||
div({ class: 'vstack gap-2', hidden: () => this.errorList.val.length == 0 || !allFinish.val },
|
||||
div({ class: 'text-danger' }, () => `以下 ${this.errorList.val.length} 个视频解析失败`),
|
||||
() => div({ class: 'list-group' },
|
||||
this.errorList.val.map(error => div({ class: 'list-group-item disabled' }, error))
|
||||
)
|
||||
),
|
||||
this.ListGroup()
|
||||
),
|
||||
this.ModalFooter()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
this.element.addEventListener('hidden.bs.modal', () => {
|
||||
this.allPlayInfo.val = []
|
||||
this.finishCount.val = this.totalCount.val
|
||||
for (const controller of this.abortControllers) {
|
||||
controller.abort()
|
||||
}
|
||||
this.abortControllers = []
|
||||
})
|
||||
|
||||
this.element.addEventListener('show.bs.modal', () => {
|
||||
this.start()
|
||||
})
|
||||
}
|
||||
|
||||
/** 开始解析 */
|
||||
async start() {
|
||||
this.finishCount.val = 0
|
||||
this.errorList.val = []
|
||||
const queue = new PQueue({ concurrency: 10 })
|
||||
for (const page of this.option.workRoute.selectedPages.val) {
|
||||
queue.add(async () => {
|
||||
if (this.totalCount.val == this.finishCount.val) return
|
||||
const controller = new AbortController()
|
||||
this.abortControllers.push(controller)
|
||||
const playInfo = await getPlayInfo(page.bvid, page.cid, controller)
|
||||
playInfo.accept_quality = [...new Set(playInfo.dash.video.map(video => video.id))].sort((a, b) => b - a)
|
||||
this.allPlayInfo.val = this.allPlayInfo.val.concat({
|
||||
page,
|
||||
info: playInfo,
|
||||
selected: van.state(true),
|
||||
formatIndex: van.state(0),
|
||||
})
|
||||
this.finishCount.val++
|
||||
}).catch(() => {
|
||||
this.finishCount.val++
|
||||
const badgeNotNum = !page.badge.match(/^\d+$/)
|
||||
this.errorList.val = this.errorList.val.concat(`${page.part}${badgeNotNum ? ` - ${page.badge}` : ''}`)
|
||||
})
|
||||
}
|
||||
await queue.onIdle()
|
||||
}
|
||||
|
||||
download() {
|
||||
const selectedPlayInfos = this.allPlayInfo.val.filter(info => info.selected.val)
|
||||
const workRoute = this.option.workRoute
|
||||
this.downloadBtnDisabled.val = true
|
||||
// 需要传递给服务器,需要创建下载任务的数据列表
|
||||
createTask(selectedPlayInfos.map(info => {
|
||||
const badgeNotNum = !info.page.badge.match(/^\d+$/)
|
||||
const isVideoMode = workRoute.videoInfoCardMode.val == 'video'
|
||||
const cardTitle = workRoute.videoInfoCardData.val.title
|
||||
const owner = workRoute.videoInfoCardData.val.staff.length > 0
|
||||
? workRoute.videoInfoCardData.val.staff[0].split("[")[0].trim()
|
||||
: workRoute.videoInfoCardData.val.owner.name.trim()
|
||||
const activeVideoInfo = getActiveFormatVideo(info.info!, info.info!.accept_quality[info.formatIndex.val], this.preferredCodec.val)
|
||||
const pagesLength = workRoute.videoInfoCardData.val.pages.length
|
||||
|
||||
return ({
|
||||
bvid: info.page.bvid,
|
||||
cid: info.page.cid,
|
||||
cover: workRoute.videoInfoCardData.val.cover,
|
||||
title: (badgeNotNum
|
||||
? [
|
||||
info.page.part.trim(),
|
||||
`[${info.page.badge.trim()}]`,
|
||||
`[${cardTitle.trim()}]`,
|
||||
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
|
||||
`[${formatSeconds(info.info!.dash.duration)}]`
|
||||
]
|
||||
: [
|
||||
pagesLength == 1 ? workRoute.allSection.val[workRoute.sectionTabsActiveIndex.val].title : `[${cardTitle.trim()}]`,
|
||||
workRoute.sectionPages.val.length == 1 ? '' : `[${info.page.badge.trim()}]`,
|
||||
info.page.part.trim(),
|
||||
isVideoMode ? `[${owner}]` : '',
|
||||
`[${videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]}]`,
|
||||
`[${formatSeconds(info.info!.dash.duration)}]`
|
||||
]).filter(p => p).join(' '),
|
||||
format: info.info!.accept_quality[info.formatIndex.val],
|
||||
owner,
|
||||
audio: getAudioURL(info.info!, this.preferHiResAudio.val),
|
||||
duration: info.info!.dash.duration,
|
||||
downloadType: this.downloadType.val,
|
||||
...activeVideoInfo
|
||||
})
|
||||
})).then(() => {
|
||||
workRoute.parseModal.hide()
|
||||
}).catch(error => {
|
||||
alert(error.message)
|
||||
}).finally(() => {
|
||||
this.downloadBtnDisabled.val = false
|
||||
})
|
||||
}
|
||||
|
||||
ParseProgress() {
|
||||
return div({ class: 'vstack gap-3', hidden: () => this.totalCount.val == this.finishCount.val },
|
||||
div({ class: 'text-center fs-5' }, () => `正在解析,剩余 ${this.totalCount.val - this.finishCount.val} 项`),
|
||||
div({ class: 'progress' }, div({
|
||||
class: 'progress-bar progress-bar-striped progress-bar-animated',
|
||||
style: () => `width: ${this.finishCount.val / this.totalCount.val * 100}%`
|
||||
},)),
|
||||
)
|
||||
}
|
||||
|
||||
ListGroup() {
|
||||
return () => div({ class: 'list-group', hidden: () => this.totalCount.val != this.finishCount.val },
|
||||
this.allPlayInfo.val.filter(info => info.info)
|
||||
.sort((a, b) => a.page.page - b.page.page)
|
||||
.map(info => {
|
||||
const badgeNotNum = !info.page.badge.match(/^\d+$/)
|
||||
|
||||
return div({
|
||||
class: () => `list-group-item user-select-none py-0 ${info.info ? '' : 'disabled'}`,
|
||||
role: 'button',
|
||||
onclick(event) {
|
||||
if ((event.target as HTMLElement).getAttribute('class')?.match(/dropdown-?/)) return
|
||||
info.selected.val = !info.selected.val
|
||||
}
|
||||
},
|
||||
div({ class: 'hstack gap-2' },
|
||||
div({ class: 'hstack gap-3 flex-fill py-1' },
|
||||
input({
|
||||
class: 'form-check-input', type: 'checkbox', checked: info.selected,
|
||||
}),
|
||||
div({},
|
||||
div((badgeNotNum ? '' : `${info.page.badge}. `) + info.page.part),
|
||||
badgeNotNum ? div({ class: info.page.part ? 'small text-secondary' : '' },
|
||||
info.page.badge) : ''
|
||||
),
|
||||
),
|
||||
div({ class: 'dropdown' },
|
||||
div({ class: 'dropdown-toggle py-2 text-primary', 'data-bs-toggle': 'dropdown' },
|
||||
() => videoFormatMap[info.info!.accept_quality[info.formatIndex.val]]
|
||||
),
|
||||
() => {
|
||||
return div({ class: 'dropdown-menu shadow' },
|
||||
info.info!.accept_quality.map((formatID, index) => {
|
||||
return div({
|
||||
class: () => `dropdown-item ${info.formatIndex.val == index ? 'active' : ''}`,
|
||||
onclick() {
|
||||
info.formatIndex.val = index
|
||||
}
|
||||
}, videoFormatMap[formatID as VideoFormat])
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
ModalFooter() {
|
||||
const _that = this
|
||||
|
||||
const selectedCount = van.derive(() => this.allPlayInfo.val.filter(info => info.selected.val).length)
|
||||
const totalCount = van.derive(() => this.allPlayInfo.val.length)
|
||||
/** 解析完成列表全部选中 */
|
||||
const allSelected = van.derive(() => selectedCount.val == totalCount.val)
|
||||
/** 是否全部解析完成 */
|
||||
const allFinish = van.derive(() => this.totalCount.val == this.finishCount.val)
|
||||
|
||||
return div({ class: `modal-footer` },
|
||||
div({ class: 'me-auto', hidden: () => !allFinish.val || totalCount.val == 0 },
|
||||
div({ class: 'hstack gap-3 text-nowrap' },
|
||||
() => `已选择 (${selectedCount.val}/${totalCount.val}) 项`,
|
||||
select({
|
||||
class: 'form-select form-select-sm',
|
||||
value: _that.downloadType,
|
||||
oninput: (e) => _that.downloadType.val = (e.target as HTMLSelectElement).value as 'audio' | 'video' | 'merge'
|
||||
},
|
||||
option({ value: 'merge' }, '音视频合并'),
|
||||
option({ value: 'audio' }, '仅音频'),
|
||||
option({ value: 'video' }, '仅视频')
|
||||
),
|
||||
select({
|
||||
class: 'form-select form-select-sm',
|
||||
value: _that.preferredCodec,
|
||||
oninput: (e) => _that.preferredCodec.val = Number((e.target as HTMLSelectElement).value) as 12 | 7 | 13
|
||||
},
|
||||
option({ value: '12' }, 'HEVC (hev1)'),
|
||||
option({ value: '7' }, 'AVC (avc1)'),
|
||||
option({ value: '13' }, 'AV1 (av01)')
|
||||
),
|
||||
div({ class: 'form-check form-check-inline' },
|
||||
input({
|
||||
type: 'checkbox',
|
||||
class: 'form-check-input',
|
||||
id: 'preferHiResAudio',
|
||||
checked: _that.preferHiResAudio,
|
||||
oninput: (e) => _that.preferHiResAudio.val = (e.target as HTMLInputElement).checked
|
||||
}),
|
||||
label({ class: 'form-check-label', for: 'preferHiResAudio' }, 'Hi-Res')
|
||||
)
|
||||
)
|
||||
),
|
||||
button({
|
||||
class: `btn btn-secondary`,
|
||||
'data-bs-dismiss': `modal`,
|
||||
hidden: allFinish
|
||||
}, '取消解析'),
|
||||
button({
|
||||
class: 'btn btn-secondary', hidden: () => !allFinish.val || allSelected.val || totalCount.val == 0,
|
||||
onclick() {
|
||||
_that.allPlayInfo.val.forEach(info => info.selected.val = true)
|
||||
}
|
||||
}, '全选'),
|
||||
button({
|
||||
class: 'btn btn-warning', hidden: () => !allFinish.val || !allSelected.val || totalCount.val == 0,
|
||||
onclick() {
|
||||
_that.allPlayInfo.val.forEach(info => info.selected.val = false)
|
||||
}
|
||||
}, '全不选'),
|
||||
button({
|
||||
class: `btn btn-primary`, onclick() {
|
||||
_that.download()
|
||||
},
|
||||
hidden: () => !allFinish.val,
|
||||
disabled: () => selectedCount.val <= 0 || _that.downloadBtnDisabled.val
|
||||
}, '开始下载'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getAudioURL = (playInfo: PlayInfo, preferHiRes: boolean = true): string => {
|
||||
if (preferHiRes && playInfo.dash.flac) {
|
||||
return playInfo.dash.flac.audio.baseUrl
|
||||
} else {
|
||||
return playInfo.dash.audio.sort((a, b) => b.id - a.id)[0].baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
const getActiveFormatVideo = (playInfo: PlayInfo, format: VideoFormat, preferredCodec: 12 | 7 | 13 = 12): { video: string, width: number, height: number } => {
|
||||
// 优先级顺序:用户首选编码格式,然后按默认优先级 12 > 7 > 13
|
||||
const codecOrder = [preferredCodec, 12, 7, 13].filter((value, index, self) => self.indexOf(value) === index)
|
||||
for (const code of codecOrder) {
|
||||
for (const item of playInfo.dash.video) {
|
||||
if (item.id == format && item.codecid == code) {
|
||||
return {
|
||||
video: item.baseUrl,
|
||||
width: item.width,
|
||||
height: item.height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('未找到对应视频分辨率格式')
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import van, { State, Val } from 'vanjs-core'
|
||||
import { VideoParseResult, VideoInfoCardMode } from '../type'
|
||||
import { secondToTime } from '../mixin'
|
||||
import { VanComponent } from '../../mixin'
|
||||
import VideoItemList from './videoItemList'
|
||||
import { WorkRoute } from '..'
|
||||
|
||||
const { a, div, img } = van.tags
|
||||
|
||||
class VideoInfoCardComp implements VanComponent {
|
||||
element: HTMLElement
|
||||
|
||||
constructor(
|
||||
public workRoute: WorkRoute,
|
||||
) {
|
||||
const {
|
||||
videoInfoCardData: data,
|
||||
videoInfoCardMode: mode,
|
||||
ownerFaceHide
|
||||
} = workRoute
|
||||
|
||||
this.element = div({ class: 'card border-2 shadow-sm' },
|
||||
div({ class: 'card-header' },
|
||||
a({
|
||||
class: 'link-dark text-decoration-none fw-bold focus-ring', href: () => data.val.targetURL,
|
||||
target: '_blank',
|
||||
},
|
||||
() => data.val.title,
|
||||
)
|
||||
),
|
||||
div({ class: 'card-body vstack gap-4' },
|
||||
div({ class: 'vstack gap-3' },
|
||||
div({ class: 'row gx-3 gy-3' },
|
||||
// 封面
|
||||
div({
|
||||
class: () => mode.val == 'video'
|
||||
? 'col-md-5 col-xl-4'
|
||||
: 'col-8 col-sm-6 mx-auto col-md-5 col-lg-3 col-xl-2'
|
||||
},
|
||||
div({ class: 'position-relative shadow-sm rounded overflow-hidden' },
|
||||
a({
|
||||
href: () => data.val.targetURL,
|
||||
title: () => `打开视频播放页面`,
|
||||
target: '_blank',
|
||||
},
|
||||
img({
|
||||
src: () => data.val.cover,
|
||||
class: 'w-100',
|
||||
ondragstart: event => event.preventDefault(),
|
||||
referrerPolicy: 'no-referrer',
|
||||
})
|
||||
),
|
||||
a({
|
||||
href: () => `https://space.bilibili.com/${data.val.owner.mid}`,
|
||||
title: () => `查看用户主页:${data.val.owner.name}`,
|
||||
target: '_blank',
|
||||
tabIndex: () => mode.val == 'video' ? 0 : -1,
|
||||
},
|
||||
img({
|
||||
src: () => data.val.owner.face,
|
||||
hidden: ownerFaceHide,
|
||||
referrerPolicy: 'no-referrer',
|
||||
ondragstart: event => event.preventDefault(),
|
||||
style: `right: 1rem; bottom: 1rem;`,
|
||||
class: 'rounded-3 border shadow position-absolute w-25'
|
||||
})
|
||||
),
|
||||
),
|
||||
),
|
||||
// 字段信息
|
||||
div({
|
||||
class: () => mode.val == 'video'
|
||||
? 'col-md-7 col-xl-8 vstack gap-2'
|
||||
: 'col-md-7 col-lg-9 col-xl-10 vstack gap-2'
|
||||
},
|
||||
div({ class: 'position-relative h-100' },
|
||||
div({ class: 'position-absolute top-0 bottom-0 position-relative-sm-down' },
|
||||
Right(this)
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
DescriptionGroup(this, true),
|
||||
),
|
||||
VideoItemList(workRoute)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const Right = (parent: VideoInfoCardComp) => {
|
||||
const mode = parent.workRoute.videoInfoCardMode
|
||||
const data = parent.workRoute.videoInfoCardData
|
||||
|
||||
return div({ class: 'vstack gap-2 h-100' },
|
||||
div({ class: 'row gx-2 gy-2' },
|
||||
div({ class: 'col-xl-7 col-xxl-8' },
|
||||
InputGroup(
|
||||
van.derive(() => mode.val == 'video'
|
||||
? (data.val.staff.length > 0 ? '制作信息' : '发布者')
|
||||
: '参演人员'),
|
||||
van.derive(() => {
|
||||
if (data.val.staff.length > 0)
|
||||
return data.val.staff.map(i => i.trim()).join(', ')
|
||||
return data.val.owner.name
|
||||
}), { disabled: true }
|
||||
),
|
||||
),
|
||||
div({ class: 'col-xl-5 col-xxl-4' },
|
||||
InputGroup('发布时间',
|
||||
van.derive(() => data.val.publishData), { disabled: true }
|
||||
)
|
||||
),
|
||||
div({ class: 'col-sm col-md-12 col-lg-4', hidden: () => mode.val != 'video' },
|
||||
InputGroup('分辨率',
|
||||
van.derive(() => `${data.val.dimension.width}x${data.val.dimension.height}`),
|
||||
{ disabled: true }
|
||||
)
|
||||
),
|
||||
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
|
||||
InputGroup('时长',
|
||||
van.derive(() => `${secondToTime(data.val.duration)}`),
|
||||
{ disabled: true }
|
||||
)
|
||||
),
|
||||
div({ class: 'col col-lg-4', hidden: () => mode.val != 'video' },
|
||||
InputGroup('集数',
|
||||
van.derive(() => data.val.pages.length.toString()),
|
||||
{ disabled: true }
|
||||
)
|
||||
),
|
||||
div({ class: 'col-md-12 col-lg-4', hidden: () => mode.val != 'season' },
|
||||
InputGroup('状态',
|
||||
van.derive(() => data.val.status),
|
||||
{ disabled: true }
|
||||
)
|
||||
),
|
||||
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
|
||||
InputGroup('地区',
|
||||
van.derive(() => data.val.areas.map(i => i.trim()).join(', ')),
|
||||
{ disabled: true }
|
||||
)
|
||||
),
|
||||
div({ class: 'col-sm col-lg-4', hidden: () => mode.val != 'season' },
|
||||
InputGroup('标签',
|
||||
van.derive(() => data.val.styles.join(', ')),
|
||||
{ disabled: true }
|
||||
)
|
||||
),
|
||||
),
|
||||
DescriptionGroup(parent),
|
||||
)
|
||||
}
|
||||
|
||||
/** 用于显示 `description` 字段的 `.input-group`
|
||||
*
|
||||
* @param parent 父组件
|
||||
* @param bottom 是否在底部
|
||||
*/
|
||||
const DescriptionGroup = (parent: VideoInfoCardComp, bottom = false) => {
|
||||
const mode = parent.workRoute.videoInfoCardMode
|
||||
const data = parent.workRoute.videoInfoCardData
|
||||
const _class = van.derive(() => mode.val == 'video' ? 'd-md-flex' : '')
|
||||
const size = van.derive(() => mode.val == 'video' ? 'lg' : 'lg')
|
||||
return div({
|
||||
class: () => `shadow-sm input-group input-group-sm ${bottom
|
||||
? `d-none d-lg-none ${_class.val}`
|
||||
: `overflow-hidden flex-fill ${mode.val == 'video' ? 'd-md-none d-lg-flex ' : ''}`
|
||||
}`,
|
||||
},
|
||||
div({ class: 'input-group-text align-items-start' }, '描述'),
|
||||
() => {
|
||||
const lines = (data.val.description.match(/^(\s*|.)$/) ? '暂无描述' : data.val.description).split('\n')
|
||||
return div({ class: `form-control overflow-auto ${bottom ? `max-height-description` : `h-100`}` },
|
||||
lines.map(line => div(line))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const InputGroup = (title: Val<string>, value: State<string>, option?: {
|
||||
disabled?: Val<boolean>
|
||||
elementType?: 'input' | 'textarea'
|
||||
}) => {
|
||||
return div({ class: 'input-group input-group-sm shadow-sm rounded' },
|
||||
div({ class: 'input-group-text' }, title),
|
||||
van.tags[option?.elementType || 'input']({
|
||||
class: 'form-control bg-white',
|
||||
disabled: option?.disabled || false,
|
||||
style: 'cursor: text;',
|
||||
value
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export default (
|
||||
workRoute: WorkRoute
|
||||
) => new VideoInfoCardComp(workRoute).element
|
||||
@@ -0,0 +1,102 @@
|
||||
import van, { State } from 'vanjs-core'
|
||||
import { VideoParseResult, VideoInfoCardMode, PageInParseResult, SectionItem } from '../type'
|
||||
import { VanComponent } from '../../mixin'
|
||||
import { WorkRoute } from '..'
|
||||
|
||||
const { button, div, span } = van.tags
|
||||
|
||||
class VideoItemListComp implements VanComponent {
|
||||
element: HTMLElement
|
||||
|
||||
constructor(
|
||||
public workRoute: WorkRoute
|
||||
) {
|
||||
const { videoInfoCardData: data } = workRoute
|
||||
|
||||
this.element = div({
|
||||
hidden: () => false && data.val.pages.length <= 1,
|
||||
class: 'vstack gap-4'
|
||||
},
|
||||
div({ class: 'vstack gap-4' },
|
||||
div({ hidden: () => workRoute.allSection.val.length == 1 && workRoute.allSection.val[0].title == '正片' }, SectionTabs(this, workRoute.allSection)),
|
||||
ButtonGroup(workRoute),
|
||||
ListBox(workRoute.sectionPages),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const SectionTabs = (parent: VideoItemListComp, allSection: State<SectionItem[]>) => {
|
||||
return () => div({ class: 'nav nav-underline' },
|
||||
allSection.val.map((item, index) => div({ class: 'nav-item', role: 'button' },
|
||||
div({
|
||||
tabIndex: 0,
|
||||
class: `nav-link ${parent.workRoute.sectionTabsActiveIndex.val == index ? 'active' : ''}`,
|
||||
onclick() {
|
||||
parent.workRoute.sectionTabsActiveIndex.val = index
|
||||
},
|
||||
onkeyup(e) {
|
||||
if (e.key == 'Enter') {
|
||||
e.target.click()
|
||||
}
|
||||
}
|
||||
}, () => item.title)
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
const ButtonGroup = (workRoute: WorkRoute) => {
|
||||
const pages = workRoute.sectionPages
|
||||
const selectedCount = van.derive(() => pages.val.filter(page => page.selected.val).length)
|
||||
const totalCount = van.derive(() => pages.val.length)
|
||||
|
||||
return div({ class: 'hstack gap-3' },
|
||||
button({
|
||||
class: 'btn btn-secondary',
|
||||
onclick() {
|
||||
pages.val.forEach(page => page.selected.val = selectedCount.val < totalCount.val)
|
||||
}
|
||||
}, () => `${selectedCount.val < totalCount.val ? '全选' : '取消全选'} (${selectedCount.val}/${totalCount.val})`),
|
||||
button({
|
||||
class: 'btn btn-primary',
|
||||
disabled: () => selectedCount.val <= 0,
|
||||
async onclick() {
|
||||
workRoute.parseModal.show()
|
||||
}
|
||||
}, '解析选中项目')
|
||||
)
|
||||
}
|
||||
|
||||
const ListBox = (pages: State<PageInParseResult[]>) => {
|
||||
return () => div({ class: 'row gy-3 gx-3' },
|
||||
pages.val.map(page => {
|
||||
const badgeNotNum = !page.badge.match(/^\d+$/)
|
||||
const active = page.selected
|
||||
return div({ class: 'col-xxl-3 col-lg-4 col-md-6' },
|
||||
div({
|
||||
tabIndex: 0,
|
||||
class: () => `${badgeNotNum
|
||||
? `vstack gap-2 justify-content-center`
|
||||
: `hstack gap-3`
|
||||
} shadow-sm h-100 text-break user-select-none card card-body video-item-btn bg-success bg-opacity-10 ${active.val ? 'active' : ''}`,
|
||||
onclick() {
|
||||
active.val = !active.val
|
||||
},
|
||||
onkeyup(e) {
|
||||
if (e.key == 'Enter') {
|
||||
active.val = !active.val
|
||||
}
|
||||
}
|
||||
},
|
||||
span({ class: 'badge text-bg-success bg-opacity-75 border', hidden: badgeNotNum }, page.badge),
|
||||
div(page.part),
|
||||
div({ class: `${page.part ? 'small text-muted' : ''}`, hidden: !badgeNotNum }, page.badge),
|
||||
)
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export default (
|
||||
workRoute: WorkRoute
|
||||
) => new VideoItemListComp(workRoute).element
|
||||
Reference in New Issue
Block a user