mirror of
https://github.com/lanyeeee/bilibili-video-downloader.git
synced 2026-09-06 08:07:26 +08:00
feat: 稍后再看
This commit is contained in:
@@ -1,3 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useStore } from '../../store.ts'
|
||||
import { commands, WatchLaterInfo } from '../../bindings.ts'
|
||||
import WatchLaterPanel from './components/WatchLaterPanel.vue'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const watchLaterInfo = ref<WatchLaterInfo>()
|
||||
|
||||
watch(
|
||||
() => store.userInfo,
|
||||
async () => {
|
||||
if (store.userInfo === undefined) {
|
||||
watchLaterInfo.value = undefined
|
||||
return
|
||||
}
|
||||
const result = await commands.getWatchLaterInfo(1)
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
watchLaterInfo.value = result.data
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>WatchLaterPane</div>
|
||||
<div v-if="watchLaterInfo !== undefined" class="h-full">
|
||||
<WatchLaterPanel v-model:watch-later-info="watchLaterInfo" />
|
||||
</div>
|
||||
<n-empty v-else class="mt-2" description="请先登录" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { MediaInWatchLater } from '../../../bindings.ts'
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import { navDownloadButtonRefKey } from '../../../injection_keys.ts'
|
||||
import { ensureHttps, extractEpId, isElementInViewport, playTaskToQueueAnimation } from '../../../utils.tsx'
|
||||
import { PhDownloadSimple, PhGoogleChromeLogo, PhMagnifyingGlass } from '@phosphor-icons/vue'
|
||||
import SimpleCheckbox from '../../../components/SimpleCheckbox.vue'
|
||||
import { SearchType } from '../../SearchPane/SearchPane.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
media: MediaInWatchLater
|
||||
downloadEpisode?: (media: MediaInWatchLater) => Promise<void>
|
||||
checkboxChecked?: (media: MediaInWatchLater) => boolean
|
||||
handleCheckboxClick?: (media: MediaInWatchLater) => void
|
||||
handleContextMenu?: (media: MediaInWatchLater) => void
|
||||
search?: (input: string, searchType: SearchType) => void
|
||||
}>()
|
||||
|
||||
const navDownloadButtonRef = inject(navDownloadButtonRefKey)
|
||||
const rootDivRef = ref<HTMLDivElement>()
|
||||
const downloadButtonRef = ref<HTMLDivElement>()
|
||||
|
||||
const openInBrowserHref = computed<string>(() => {
|
||||
if (props.media.redirect_url === null) {
|
||||
return `https://www.bilibili.com/video/${props.media.bvid}/`
|
||||
} else {
|
||||
const epId = extractEpId(props.media.redirect_url)
|
||||
return `https://www.bilibili.com/bangumi/play/ep${epId}`
|
||||
}
|
||||
})
|
||||
|
||||
async function handleDownloadClick() {
|
||||
if (props.downloadEpisode === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
await props.downloadEpisode(props.media)
|
||||
playDownloadAnimation()
|
||||
}
|
||||
|
||||
function playDownloadAnimation() {
|
||||
if (rootDivRef.value === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const from = downloadButtonRef.value
|
||||
const to = navDownloadButtonRef?.value
|
||||
|
||||
if (from instanceof Element && to !== undefined) {
|
||||
if (isElementInViewport(rootDivRef.value)) {
|
||||
// 只有卡片在视口内才播放动画
|
||||
playTaskToQueueAnimation(from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function searchInSearchPane() {
|
||||
if (props.search === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.media.redirect_url === null) {
|
||||
props.search(props.media.bvid, 'Normal')
|
||||
} else {
|
||||
const epId = extractEpId(props.media.redirect_url)
|
||||
props.search(`ep${epId}`, 'Bangumi')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ playDownloadAnimation, media: props.media })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-200px relative p-3 rounded-lg"
|
||||
@contextmenu="handleContextMenu?.(media)"
|
||||
:title="media.redirect_url !== null ? '下载请点击左下角放大镜按钮' : undefined"
|
||||
ref="rootDivRef">
|
||||
<SimpleCheckbox
|
||||
v-if="media.redirect_url === null && handleCheckboxClick !== undefined && checkboxChecked !== undefined"
|
||||
class="absolute top-6 left-6 z-1 backdrop-blur-2"
|
||||
:checked="checkboxChecked(media)"
|
||||
:on-click="() => handleCheckboxClick?.(media)" />
|
||||
<div v-if="media.redirect_url !== null" class="absolute top-6 right-6 z-1 bg-[#ff6699] text-white px-1 rounded">
|
||||
番剧
|
||||
</div>
|
||||
<img
|
||||
class="w-200px h-125px rounded-lg object-cover lazyload"
|
||||
:data-src="`${ensureHttps(media.pic)}@672w_378h_1c.webp`"
|
||||
:key="media.pic"
|
||||
alt=""
|
||||
draggable="false" />
|
||||
|
||||
<div class="w-full flex flex-col h-45px mt-2">
|
||||
<span class="line-clamp-2" :title="media.title">{{ media.title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center whitespace-nowrap text-gray text-12px w-full overflow-hidden">
|
||||
<a
|
||||
class="min-w-0 color-inherit no-underline hover:text-sky-5 mr-1"
|
||||
:href="`https://space.bilibili.com/${media.owner.mid}`"
|
||||
target="_blank"
|
||||
draggable="false">
|
||||
<div class="truncate text-ellipsis" :title="media.owner.name">{{ media.owner.name }}</div>
|
||||
</a>
|
||||
<span class="ml-auto flex-shrink-0" title="发布时间">
|
||||
<n-time unix type="date" :time="media.pubdate" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<a
|
||||
:href="openInBrowserHref"
|
||||
target="_blank"
|
||||
draggable="false"
|
||||
title="在浏览器中打开"
|
||||
class="p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6">
|
||||
<PhGoogleChromeLogo :size="24" />
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="search !== undefined"
|
||||
title="在下载器内搜索"
|
||||
class="cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="searchInSearchPane">
|
||||
<PhMagnifyingGlass :size="24" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="downloadEpisode !== undefined"
|
||||
ref="downloadButtonRef"
|
||||
title="一键下载"
|
||||
class="ml-auto cursor-pointer p-1 rounded-lg flex items-center justify-between text-gray-6 hover:bg-sky-5 hover:text-white active:bg-sky-6"
|
||||
@click="handleDownloadClick">
|
||||
<PhDownloadSimple :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { commands, MediaInWatchLater, WatchLaterInfo } from '../../../bindings.ts'
|
||||
import { computed, inject, ref, watch } from 'vue'
|
||||
import { useEpisodeDropdown, useEpisodeSelection } from '../../../utils.tsx'
|
||||
import { SelectionArea } from '@viselect/vue'
|
||||
import { searchPaneRefKey } from '../../../injection_keys.ts'
|
||||
import WatchLaterCard from './WatchLaterCard.vue'
|
||||
|
||||
const watchLaterInfo = defineModel<WatchLaterInfo>('watchLaterInfo', { required: true })
|
||||
|
||||
const searchPaneRef = inject(searchPaneRefKey)
|
||||
|
||||
const currentPage = ref<number>(1)
|
||||
|
||||
const pageCount = computed<number>(() => {
|
||||
if (watchLaterInfo.value === undefined) {
|
||||
return 1
|
||||
}
|
||||
return Math.ceil(watchLaterInfo.value.count / 20)
|
||||
})
|
||||
|
||||
const watchLaterCardRefs = ref<InstanceType<typeof WatchLaterCard>[]>([])
|
||||
const watchLaterCardRefsMap = computed<Map<number, InstanceType<typeof WatchLaterCard>>>(() => {
|
||||
const map = new Map<number, InstanceType<typeof WatchLaterCard>>()
|
||||
watchLaterCardRefs.value.forEach((card) => map.set(card.media.aid, card))
|
||||
return map
|
||||
})
|
||||
|
||||
const { selectedIds, updateSelectedIds, unselectAll } = useEpisodeSelection()
|
||||
const selectionAreaRef = ref<InstanceType<typeof SelectionArea>>()
|
||||
const checkedIds = ref<Set<number>>(new Set())
|
||||
|
||||
watch(watchLaterInfo, () => {
|
||||
selectedIds.value.clear()
|
||||
checkedIds.value.clear()
|
||||
selectionAreaRef.value?.selection?.clearSelection()
|
||||
selectionAreaRef.value?.$el.scrollTo({ top: 0, behavior: 'instant' })
|
||||
})
|
||||
|
||||
const { dropdownX, dropdownY, dropdownShowing, dropdownOptions, showDropdown } = useEpisodeDropdown(
|
||||
() => {
|
||||
selectedIds.value.forEach((aid) => checkedIds.value.add(aid))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
selectedIds.value.forEach((aid) => checkedIds.value.delete(aid))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
() => {
|
||||
watchLaterInfo.value.list?.forEach((media) => selectedIds.value.add(media.aid))
|
||||
dropdownShowing.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const { downloadEpisode, checkboxChecked, handleCheckboxClick, handleContextMenu } = useWatchLaterCard(
|
||||
async (media: MediaInWatchLater) => {
|
||||
await downloadNormalEpisode(media.aid)
|
||||
},
|
||||
(media: MediaInWatchLater) => {
|
||||
return checkedIds.value.has(media.aid)
|
||||
},
|
||||
(media: MediaInWatchLater) => {
|
||||
const checked = checkedIds.value.has(media.aid)
|
||||
if (checked) {
|
||||
checkedIds.value.delete(media.aid)
|
||||
} else {
|
||||
checkedIds.value.add(media.aid)
|
||||
}
|
||||
},
|
||||
(media: MediaInWatchLater) => {
|
||||
if (selectedIds.value.has(media.aid)) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedIds.value.clear()
|
||||
selectedIds.value.add(media.aid)
|
||||
const selection = selectionAreaRef.value?.selection
|
||||
if (selection) {
|
||||
selection.clearSelection()
|
||||
selection.select(`[data-key="${media.aid}"]`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function downloadNormalEpisode(aid: number) {
|
||||
// 获取普通视频信息,用于创建下载任务
|
||||
const getNormalInfoResult = await commands.getNormalInfo({ Aid: aid })
|
||||
if (getNormalInfoResult.status === 'error') {
|
||||
console.error(getNormalInfoResult.error)
|
||||
return
|
||||
}
|
||||
// 创建下载任务
|
||||
await commands.createDownloadTasks({ Normal: { info: getNormalInfoResult.data, aid_cid_pairs: [[aid, null]] } })
|
||||
}
|
||||
|
||||
async function downloadCheckedEpisodes() {
|
||||
for (const aid of checkedIds.value) {
|
||||
// 创建下载任务
|
||||
await downloadNormalEpisode(aid)
|
||||
// 播放下载动画
|
||||
const card = watchLaterCardRefsMap.value.get(aid)
|
||||
if (card !== undefined) {
|
||||
card.playDownloadAnimation()
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
}
|
||||
}
|
||||
|
||||
async function getWatchLaterInfo(page: number) {
|
||||
currentPage.value = page
|
||||
const result = await commands.getWatchLaterInfo(page)
|
||||
if (result.status === 'error') {
|
||||
console.error(result.error)
|
||||
return
|
||||
}
|
||||
watchLaterInfo.value = result.data
|
||||
}
|
||||
|
||||
function useWatchLaterCard(
|
||||
downloadEpisode: (media: MediaInWatchLater) => Promise<void>,
|
||||
checkboxChecked: (media: MediaInWatchLater) => boolean,
|
||||
handleCheckboxClick: (media: MediaInWatchLater) => void,
|
||||
handleContextMenu: (media: MediaInWatchLater) => void,
|
||||
) {
|
||||
return {
|
||||
downloadEpisode,
|
||||
checkboxChecked,
|
||||
handleCheckboxClick,
|
||||
handleContextMenu,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full select-none overflow-auto">
|
||||
<SelectionArea
|
||||
ref="selectionAreaRef"
|
||||
class="selection-container flex flex-col flex-1 px-2 overflow-auto"
|
||||
:options="{ selectables: '.selectable', features: { deselectOnBlur: true } }"
|
||||
@contextmenu="showDropdown"
|
||||
@move="updateSelectedIds"
|
||||
@start="unselectAll">
|
||||
<div class="animate-pulse text-violet">左键拖动进行框选,右键打开菜单</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<template v-for="media in watchLaterInfo.list" :key="media.aid">
|
||||
<WatchLaterCard
|
||||
v-if="media.redirect_url === null"
|
||||
ref="watchLaterCardRefs"
|
||||
:data-key="media.aid"
|
||||
:class="[
|
||||
'selectable border border-solid border-transparent',
|
||||
selectedIds.has(media.aid) ? 'selected shadow-md' : 'hover:bg-gray-1',
|
||||
]"
|
||||
:media="media"
|
||||
:download-episode="downloadEpisode"
|
||||
:checkbox-checked="checkboxChecked"
|
||||
:handle-checkbox-click="handleCheckboxClick"
|
||||
:handle-context-menu="handleContextMenu"
|
||||
:search="searchPaneRef?.search" />
|
||||
<WatchLaterCard
|
||||
v-else
|
||||
ref="favCardRefs"
|
||||
class="border border-solid border-transparent hover:border-gray-3"
|
||||
:media="media"
|
||||
:search="searchPaneRef?.search" />
|
||||
</template>
|
||||
</div>
|
||||
</SelectionArea>
|
||||
|
||||
<div class="flex gap-2 m-2 box-border">
|
||||
<n-pagination :page-count="pageCount" :page="currentPage" @update:page="getWatchLaterInfo($event)" />
|
||||
<n-button class="ml-auto" size="small" type="primary" @click="downloadCheckedEpisodes">下载勾选视频</n-button>
|
||||
</div>
|
||||
|
||||
<n-dropdown
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="dropdownX"
|
||||
:y="dropdownY"
|
||||
:options="dropdownOptions"
|
||||
:show="dropdownShowing"
|
||||
:on-clickoutside="() => (dropdownShowing = false)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selection-container .selected {
|
||||
@apply bg-[rgb(204,232,255)];
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user