mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-15 01:13:25 +08:00
添加 PlayingBackdropCard 组件并更新相关视图,优化媒体服务器设置和播放列表的加载逻辑
This commit is contained in:
@@ -2666,6 +2666,7 @@ onScopeDispose(() => {
|
||||
-ms-overflow-style: none;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
overscroll-behavior-y: contain;
|
||||
padding-block: 1rem;
|
||||
padding-inline: 1rem;
|
||||
scroll-behavior: auto;
|
||||
|
||||
412
src/components/cards/PlayingBackdropCard.vue
Normal file
412
src/components/cards/PlayingBackdropCard.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MediaServerPlayItem } from '@/api/types'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { openMediaServerItem } from '@/utils/appDeepLink'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
media: Object as PropType<MediaServerPlayItem>,
|
||||
width: String,
|
||||
height: String,
|
||||
})
|
||||
|
||||
// 图片是否加载完成
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 卡片内联尺寸,便于仪表盘网格按高度进行虚拟布局。
|
||||
const cardStyle = computed(() => ({
|
||||
aspectRatio: props.height ? undefined : '16 / 10',
|
||||
blockSize: props.height,
|
||||
inlineSize: props.width || '100%',
|
||||
}))
|
||||
|
||||
// 规范化后的播放进度,避免后端异常值撑破进度条。
|
||||
const progressValue = computed(() => {
|
||||
const percent = Number(props.media?.percent ?? 0)
|
||||
if (Number.isNaN(percent)) return 0
|
||||
return Math.min(Math.max(percent, 0), 100)
|
||||
})
|
||||
|
||||
// 是否展示继续播放状态。
|
||||
const hasProgress = computed(() => progressValue.value > 0)
|
||||
|
||||
// 顶部状态标签。
|
||||
const statusLabel = computed(() => (hasProgress.value ? '正在播放' : '待播放'))
|
||||
|
||||
// 右上角进度标签。
|
||||
const progressLabel = computed(() => (hasProgress.value ? `${Math.round(progressValue.value)}%` : 'NEW'))
|
||||
|
||||
// 副信息兜底,避免空副标题导致卡片底部信息层过空。
|
||||
const subtitleText = computed(() => props.media?.subtitle || props.media?.type || '继续观看')
|
||||
|
||||
// 根据播放进度生成简短提示。
|
||||
const progressHint = computed(() => {
|
||||
if (!hasProgress.value) return '未开始'
|
||||
if (progressValue.value >= 90) return '快看完'
|
||||
if (progressValue.value <= 10) return '刚开始'
|
||||
return '继续上次进度'
|
||||
})
|
||||
|
||||
// 计算图片代理地址。
|
||||
const imageUrl = computed(() => {
|
||||
const image = props.media?.image || ''
|
||||
if (!image || imageLoadError.value) return noImage
|
||||
|
||||
let url = `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(image)}`
|
||||
const useCookies = props.media?.use_cookies
|
||||
if (useCookies) {
|
||||
url += `&use_cookies=${encodeURIComponent(useCookies)}`
|
||||
}
|
||||
|
||||
return url
|
||||
})
|
||||
|
||||
/**
|
||||
* 标记封面加载完成。
|
||||
*/
|
||||
function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记封面加载失败并切换到默认图。
|
||||
*/
|
||||
function imageErrorHandler() {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到媒体服务器播放页面。
|
||||
*/
|
||||
async function goPlay() {
|
||||
if (props.media) {
|
||||
await openMediaServerItem(props.media)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-bind="hover.props" class="playing-card-hover-area">
|
||||
<VCard
|
||||
:style="cardStyle"
|
||||
class="playing-card app-hover-lift-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering playing-card--hovering': hover.isHovering,
|
||||
'playing-card--loaded': imageLoaded,
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="goPlay"
|
||||
@keyup.enter="goPlay"
|
||||
@keyup.space="goPlay"
|
||||
>
|
||||
<VImg
|
||||
:src="imageUrl"
|
||||
class="playing-card__image"
|
||||
:class="{ 'playing-card__image--loaded': imageLoaded }"
|
||||
cover
|
||||
@load="imageLoadHandler"
|
||||
@error="imageErrorHandler"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="playing-card__placeholder">
|
||||
<VSkeletonLoader class="playing-card__skeleton" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
|
||||
<div class="playing-card__scrim" />
|
||||
<div class="playing-card__bottom-scrim" />
|
||||
|
||||
<div class="playing-card__status">
|
||||
<span class="playing-card__status-dot" />
|
||||
<span class="playing-card__status-text">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="playing-card__percent">
|
||||
{{ progressLabel }}
|
||||
</div>
|
||||
|
||||
<div class="playing-card__play" aria-hidden="true">
|
||||
<VIcon icon="mdi-play" size="26" />
|
||||
</div>
|
||||
|
||||
<div class="playing-card__content">
|
||||
<div class="playing-card__title">
|
||||
{{ props.media?.title }}
|
||||
</div>
|
||||
<div class="playing-card__subtitle">
|
||||
{{ subtitleText }}
|
||||
</div>
|
||||
<div class="playing-card__meta">
|
||||
<span>{{ progressHint }}</span>
|
||||
<span v-if="props.media?.server_type" class="playing-card__server">
|
||||
{{ props.media.server_type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playing-card__progress" aria-hidden="true">
|
||||
<div class="playing-card__progress-track">
|
||||
<div class="playing-card__progress-bar" :style="{ inlineSize: `${progressValue}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</div>
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* stylelint-disable selector-pseudo-class-no-unknown */
|
||||
|
||||
.playing-card-hover-area {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-border-color), 0.16);
|
||||
border-radius: 16px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 12%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.playing-card--hovering {
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 20%);
|
||||
}
|
||||
|
||||
.playing-card__image {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__placeholder,
|
||||
.playing-card__skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.playing-card__image :deep(.v-img__img) {
|
||||
filter: saturate(0.96) brightness(0.92);
|
||||
opacity: 0;
|
||||
transform: scale(1.01);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
|
||||
.playing-card__image--loaded :deep(.v-img__img) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img) {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.playing-card__scrim,
|
||||
.playing-card__bottom-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.playing-card__scrim {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(0, 0, 0, 24%) 0%, rgba(0, 0, 0, 4%) 48%, rgba(0, 0, 0, 28%) 100%),
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 8%) 0%, rgba(0, 0, 0, 0%) 42%);
|
||||
}
|
||||
|
||||
.playing-card__bottom-scrim {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 6, 12, 0%) 24%, rgba(2, 6, 12, 72%) 68%, rgba(2, 6, 12, 96%) 100%);
|
||||
}
|
||||
|
||||
.playing-card__status,
|
||||
.playing-card__percent,
|
||||
.playing-card__play,
|
||||
.playing-card__content,
|
||||
.playing-card__progress {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playing-card__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 22%);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(5, 10, 16, 72%);
|
||||
block-size: 26px;
|
||||
gap: 8px;
|
||||
inset-block-start: 12px;
|
||||
inset-inline-start: 12px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.playing-card__status-dot {
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--v-theme-success));
|
||||
block-size: 8px;
|
||||
box-shadow: 0 0 0 4px rgba(var(--v-theme-success), 16%);
|
||||
inline-size: 8px;
|
||||
}
|
||||
|
||||
.playing-card__status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.playing-card__percent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255, 255, 255, 18%);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(12px);
|
||||
background: rgba(255, 255, 255, 16%);
|
||||
block-size: 26px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
inline-size: 52px;
|
||||
inset-block-start: 12px;
|
||||
inset-inline-end: 12px;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 92%);
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 24%);
|
||||
color: rgb(11, 15, 20);
|
||||
inline-size: 40px;
|
||||
inset-block-end: 46px;
|
||||
inset-inline-end: 18px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__play {
|
||||
background: #fff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.playing-card__content {
|
||||
inset-block-end: 18px;
|
||||
inset-inline: 16px 66px;
|
||||
min-inline-size: 0;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 45%);
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
overflow: hidden;
|
||||
font-size: 1.08rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__subtitle {
|
||||
overflow: hidden;
|
||||
margin-block-start: 4px;
|
||||
color: rgba(255, 255, 255, 86%);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__meta {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-block-start: 7px;
|
||||
color: rgba(255, 255, 255, 70%);
|
||||
font-size: 0.72rem;
|
||||
gap: 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playing-card__server {
|
||||
overflow: hidden;
|
||||
max-inline-size: 6rem;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.playing-card__progress {
|
||||
inset-block-end: 6px;
|
||||
inset-inline: 16px;
|
||||
}
|
||||
|
||||
.playing-card__progress-track {
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 24%);
|
||||
block-size: 5px;
|
||||
}
|
||||
|
||||
.playing-card__progress-bar {
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgb(var(--v-theme-success)) 0%, rgba(255, 255, 255, 92%) 100%);
|
||||
block-size: 100%;
|
||||
min-inline-size: 8px;
|
||||
transition: inline-size 0.25s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.playing-card {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.playing-card__content {
|
||||
inset-inline-end: 58px;
|
||||
}
|
||||
|
||||
.playing-card__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.playing-card__play {
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
inset-inline-end: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playing-card__image :deep(.v-img__img),
|
||||
.playing-card__play,
|
||||
.playing-card__progress-bar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.playing-card--hovering .playing-card__image :deep(.v-img__img),
|
||||
.playing-card--hovering .playing-card__play {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -895,6 +895,7 @@ html[data-theme='transparent'] .search-desktop-activator .search-input-wrapper,
|
||||
.search-content {
|
||||
max-block-size: 600px;
|
||||
min-block-size: 150px;
|
||||
overscroll-behavior-y: contain;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>({})
|
||||
// 所有媒体服务器设置
|
||||
const mediaServers = ref<MediaServerConf[]>([])
|
||||
|
||||
// 调用API查询媒体服务器设置
|
||||
/**
|
||||
* 查询媒体服务器设置。
|
||||
*/
|
||||
async function loadMediaServerSetting() {
|
||||
try {
|
||||
const response: { data: { value: MediaServerConf[] } } = await api.get('system/setting/MediaServers')
|
||||
@@ -24,7 +26,10 @@ async function loadMediaServerSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询最近入库
|
||||
/**
|
||||
* 查询指定媒体服务器的最近入库列表。
|
||||
* @param server 媒体服务器名称
|
||||
*/
|
||||
async function loadLatest(server: string) {
|
||||
try {
|
||||
const response: MediaServerPlayItem[] = await api.get('mediaserver/latest', { params: { server } })
|
||||
@@ -37,7 +42,9 @@ async function loadLatest(server: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
/**
|
||||
* 加载已启用媒体服务器的最近入库数据。
|
||||
*/
|
||||
async function loadData() {
|
||||
await loadMediaServerSetting()
|
||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||
@@ -58,7 +65,7 @@ onActivated(() => {
|
||||
<template>
|
||||
<div class="dashboard-media-stack" :class="{ 'dashboard-grid-fill': Object.keys(latestList).length > 0 }">
|
||||
<VCard v-for="(data, name) in latestList" :key="name" class="dashboard-work-card dashboard-media-card">
|
||||
<VCardItem>
|
||||
<VCardItem class="dashboard-media-header">
|
||||
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
@@ -103,6 +110,10 @@ onActivated(() => {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-media-header {
|
||||
padding-block-end: 0.375rem;
|
||||
}
|
||||
|
||||
.dashboard-media-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -14,7 +14,9 @@ const libraryList = ref<MediaServerLibrary[]>([])
|
||||
// 所有媒体服务器设置
|
||||
const mediaServers = ref<MediaServerConf[]>([])
|
||||
|
||||
// 调用API查询媒体服务器设置
|
||||
/**
|
||||
* 查询媒体服务器设置。
|
||||
*/
|
||||
async function loadMediaServerSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
||||
@@ -24,7 +26,10 @@ async function loadMediaServerSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询
|
||||
/**
|
||||
* 查询指定媒体服务器的媒体库。
|
||||
* @param server 媒体服务器名称
|
||||
*/
|
||||
async function loadLibrary(server: string) {
|
||||
try {
|
||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', {
|
||||
@@ -42,7 +47,9 @@ async function loadLibrary(server: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
/**
|
||||
* 加载已启用媒体服务器的媒体库数据。
|
||||
*/
|
||||
async function loadData() {
|
||||
await loadMediaServerSetting()
|
||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||
@@ -62,7 +69,7 @@ onActivated(() => {
|
||||
|
||||
<template>
|
||||
<VCard v-if="libraryList.length > 0" class="dashboard-media-card dashboard-grid-fill">
|
||||
<VCardItem>
|
||||
<VCardItem class="dashboard-media-header">
|
||||
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<div class="dashboard-media-content px-5 pb-3">
|
||||
@@ -97,6 +104,10 @@ onActivated(() => {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-media-header {
|
||||
padding-block-end: 0.375rem;
|
||||
}
|
||||
|
||||
.dashboard-media-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||
import BackdropCard from '@/components/cards/BackdropCard.vue'
|
||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -14,7 +14,9 @@ const playingList = ref<MediaServerPlayItem[]>([])
|
||||
// 所有媒体服务器设置
|
||||
const mediaServers = ref<MediaServerConf[]>([])
|
||||
|
||||
// 调用API查询媒体服务器设置
|
||||
/**
|
||||
* 查询媒体服务器设置。
|
||||
*/
|
||||
async function loadMediaServerSetting() {
|
||||
try {
|
||||
const result: { [key: string]: any } = await api.get('system/setting/MediaServers')
|
||||
@@ -24,7 +26,10 @@ async function loadMediaServerSetting() {
|
||||
}
|
||||
}
|
||||
|
||||
// 调用API查询
|
||||
/**
|
||||
* 查询指定媒体服务器的继续观看列表。
|
||||
* @param server 媒体服务器名称
|
||||
*/
|
||||
async function loadPlayingList(server: string) {
|
||||
try {
|
||||
const result: MediaServerPlayItem[] = await api.get('mediaserver/playing', { params: { server } })
|
||||
@@ -42,7 +47,9 @@ async function loadPlayingList(server: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载数据
|
||||
/**
|
||||
* 加载已启用媒体服务器的继续观看数据。
|
||||
*/
|
||||
async function loadData() {
|
||||
await loadMediaServerSetting()
|
||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||
@@ -62,7 +69,7 @@ onActivated(() => {
|
||||
|
||||
<template>
|
||||
<VCard v-if="playingList.length > 0" class="dashboard-media-card dashboard-grid-fill">
|
||||
<VCardItem>
|
||||
<VCardItem class="dashboard-media-header">
|
||||
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
|
||||
@@ -72,11 +79,11 @@ onActivated(() => {
|
||||
:items="playingList"
|
||||
:get-item-key="item => item.id || item.link || item.title"
|
||||
:min-item-width="240"
|
||||
:estimated-item-height="160"
|
||||
:estimated-item-height="174"
|
||||
tabindex="0"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<BackdropCard :media="item" height="10rem" />
|
||||
<PlayingBackdropCard :media="item" height="10.875rem" />
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</div>
|
||||
@@ -98,6 +105,10 @@ onActivated(() => {
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.dashboard-media-header {
|
||||
padding-block-end: 0.375rem;
|
||||
}
|
||||
|
||||
.dashboard-media-content {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
|
||||
Reference in New Issue
Block a user