Files
MoviePilot-Frontend/src/views/discover/MediaDetailView.vue

1267 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useToast } from 'vue-toastification'
import PersonCardSlideView from './PersonCardSlideView.vue'
import MediaCardSlideView from './MediaCardSlideView.vue'
import api from '@/api'
import type { MediaInfo, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import { doneNProgress, startNProgress } from '@/api/nprogress'
import { formatSeason } from '@/@core/utils/formatters'
import router from '@/router'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useUserStore } from '@/stores'
import SubscribeEditDialog from '@/components/dialog/SubscribeEditDialog.vue'
import SearchSiteDialog from '@/components/dialog/SearchSiteDialog.vue'
import { useTheme } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { hasPermission } from '@/utils/permission'
// 国际化
const { t } = useI18n()
// 输入参数
const mediaProps = defineProps({
mediaid: String,
title: String,
year: String,
type: String,
})
// 从 provide 中获取全局设置
const globalSettings: any = inject('globalSettings')
// 用户 Store
const userStore = useUserStore()
// 提示框
const $toast = useToast()
// 获取主题信息
const theme = useTheme()
// 媒体详情
const mediaDetail = ref<MediaInfo>({} as MediaInfo)
// 订阅编辑弹窗
const subscribeEditDialog = ref(false)
// 本地是否存在存在则包括Item信息
const existsItemId = ref('')
// 是否已订阅
const isSubscribed = ref(false)
// 是否已加载完成
const isRefreshed = ref(false)
// 存储每一季的集信息
const seasonEpisodesInfo = ref({} as { [key: number]: TmdbEpisode[] })
// 存储存在的季集
const existsEpisodes = ref({} as { [key: number]: number[] })
// 各季缺失状态0-已入库 1-部分缺失 2-全部缺失,没有数据也是已入库
const seasonsNotExisted = ref<{ [key: number]: number }>({})
// 各季的订阅状态
const seasonsSubscribed = ref<{ [key: number]: boolean }>({})
// 订阅编号
const subscribeId = ref<number>()
// 所有站点
const allSites = ref<Site[]>([])
// 选中的站点
const selectedSites = ref<number[]>([])
// 搜索方式 title/imdbid
const searchType = ref('title')
// 选择站点对话框
const chooseSiteDialog = ref(false)
// 计算主题是否为透明
const isNonTransparentTheme = computed(() => {
return theme.name.value !== 'transparent'
})
// 查询所有站点
async function querySites() {
try {
const data: Site[] = await api.get('site/')
// 过滤站点,只有启用的站点才显示
allSites.value = data.filter(item => item.is_active)
} catch (error) {
console.log(error)
}
}
// 查询用户选中的站点
async function querySelectedSites() {
try {
const result: { [key: string]: any } = await api.get('system/setting/IndexerSites')
selectedSites.value = result.data?.value ?? []
} catch (error) {
console.log(error)
}
}
// 获得mediaid
function getMediaId() {
if (mediaDetail.value?.tmdb_id) return `tmdb:${mediaDetail.value?.tmdb_id}`
else if (mediaDetail.value?.douban_id) return `douban:${mediaDetail.value?.douban_id}`
else if (mediaDetail.value?.bangumi_id) return `bangumi:${mediaDetail.value?.bangumi_id}`
else return `${mediaDetail.value?.mediaid_prefix}:${mediaDetail.value?.media_id}`
}
// 调用API查询详情
async function getMediaDetail() {
if (mediaProps.mediaid && mediaProps.type) {
mediaDetail.value = await api.get(`media/${mediaProps.mediaid}`, {
params: {
title: mediaProps.title,
year: mediaProps.year,
type_name: mediaProps.type,
},
})
isRefreshed.value = true
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return
// 检查存在状态
checkExists()
if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
// 检查订阅状态
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
else checkSeasonsSubscribed()
}
}
// 调用API加载季集信息TMDB
async function loadSeasonEpisodes(season: number) {
// 加载季集存在信息
loadEpisodeExists()
// 加载季集信息
if (seasonEpisodesInfo.value[season]) return
try {
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`)
seasonEpisodesInfo.value[season] = result || []
} catch (error) {
console.error(error)
}
}
// 调用API加载季集存在信息媒体服务器
async function loadEpisodeExists() {
// 查询季集存在状态
if (!isNullOrEmptyObject(existsEpisodes.value)) return
try {
const result: { [key: number]: number[] } = await api.post(`mediaserver/exists_remote`, mediaDetail.value)
existsEpisodes.value = result || {}
} catch (error) {
console.error(error)
}
}
// 查询当前媒体是否已入库(数据库)
async function checkExists() {
try {
const result: { [key: string]: any } = await api.get('mediaserver/exists', {
params: {
tmdbid: mediaDetail.value.tmdb_id,
title: mediaDetail.value.title,
year: mediaDetail.value.year,
season: mediaDetail.value.season,
mtype: mediaDetail.value.type,
},
})
if (result.success) existsItemId.value = result.data.item.id
} catch (error) {
console.error(error)
}
}
// 查询当前媒体是否已订阅
async function checkSubscribe(season = 0) {
try {
const mediaid = getMediaId()
const result: Subscribe = await api.get(`subscribe/media/${mediaid}`, {
params: {
season,
title: mediaDetail.value.title,
},
})
if (result.id) return true
} catch (error) {
console.error(error)
}
return false
}
// 检查所有季的缺失状态
async function checkSeasonsNotExists() {
if (mediaDetail.value.type !== '电视剧') return
try {
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', mediaDetail.value)
if (result) {
result.forEach(item => {
// 0-已入库 1-部分缺失 2-全部缺失
let state = 0
if (item.episodes.length === 0) state = 2
else if (item.episodes.length < item.total_episode) state = 1
seasonsNotExisted.value[item.season] = state
})
}
} catch (error) {
console.error(error)
}
}
// 检查电影订阅状态
async function checkMovieSubscribed() {
if (mediaDetail.value.type !== '电影') return
isSubscribed.value = await checkSubscribe()
}
// 过滤掉第0季
const getMediaSeasons = computed(() => {
return mediaDetail.value?.season_info?.filter(season => season.season_number !== 0)
})
// 检查所有季的订阅状态
async function checkSeasonsSubscribed() {
if (mediaDetail.value.type !== '电视剧') return
try {
mediaDetail.value?.season_info?.forEach(async item => {
seasonsSubscribed.value[item.season_number ?? 0] = await checkSubscribe(item.season_number)
})
} catch (error) {
console.error(error)
}
}
// 调用API添加订阅电视剧的话需要指定季
async function addSubscribe(season = 0) {
// 开始处理
startNProgress()
try {
// 是否洗版
let best_version = existsItemId.value ? 1 : 0
if (season)
// 全部存在时洗版
best_version = !seasonsNotExisted.value[season] ? 1 : 0
// 请求API
const result: { [key: string]: any } = await api.post('subscribe/', {
name: mediaDetail.value?.title,
type: mediaDetail.value?.type,
year: mediaDetail.value?.year,
tmdbid: mediaDetail.value?.tmdb_id,
doubanid: mediaDetail.value?.douban_id,
bangumiid: mediaDetail.value?.bangumi_id,
season,
best_version,
})
// 订阅状态
if (result.success) {
// 订阅成功
isSubscribed.value = true
if (season) seasonsSubscribed.value[season] = true
}
// 提示
showSubscribeAddToast(result.success, mediaDetail.value?.title ?? '', season, result.message, best_version)
// 显示编辑弹窗
if (result.success) {
const show_edit_dialog = await queryDefaultSubscribeConfig()
if (show_edit_dialog) {
subscribeId.value = result.data.id
subscribeEditDialog.value = true
}
}
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 弹出添加订阅提示
function showSubscribeAddToast(result: boolean, title: string, season: number, message: string, best_version: number) {
if (season) title = `${title} ${formatSeason(season.toString())}`
let subname = t('media.subscribe.normal')
if (best_version > 0) subname = t('media.subscribe.bestVersion')
if (!result) $toast.error(`${title} ${t('media.subscribe.addFailed', { reason: message })}`)
}
// 调用API取消订阅
async function removeSubscribe(season: number) {
// 开始处理
startNProgress()
try {
const mediaid = getMediaId()
const result: { [key: string]: any } = await api.delete(`subscribe/media/${mediaid}`, {
params: {
season,
},
})
if (result.success) {
isSubscribed.value = false
if (season) seasonsSubscribed.value[season] = false
$toast.success(`${mediaDetail.value?.title} ${t('media.subscribe.canceled')}`)
} else {
$toast.error(`${mediaDetail.value?.title} ${t('media.subscribe.cancelFailed', { reason: result.message })}`)
}
} catch (error) {
console.error(error)
}
doneNProgress()
}
// 订阅按钮响应
function handleSubscribe(season = 0) {
if (isSubscribed.value) removeSubscribe(season)
else addSubscribe(season)
}
// 从genres中获取name使用、分隔
function getGenresName(genres: any[]) {
return genres.map(genre => genre.name).join('、')
}
// 拼装TheMovieDb地址
function getTheMovieDbLink() {
const mtype = mediaProps.type === '电影' ? 'movie' : 'tv'
return `https://www.themoviedb.org/${mtype}/${mediaDetail.value.tmdb_id}`
}
// 拼装豆瓣地址
function getDoubanLink() {
return `https://movie.douban.com/subject/${mediaDetail.value.douban_id}`
}
// 拼装IMDB地址
function getImdbLink() {
return `https://www.imdb.com/title/${mediaDetail.value.imdb_id}`
}
// 拼装TVDB地址
function getTvdbLink() {
return `https://www.thetvdb.com/series/${mediaDetail.value.tvdb_id}`
}
// 拼装Bangumi地址
function getBangumiLink() {
return `https://bgm.tv/subject/${mediaDetail.value.bangumi_id}`
}
// 拼装集图片地址
function getEpisodeImage(stillPath: string) {
if (!stillPath) return ''
return `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w500${stillPath}`
}
// TMDB图片转换为w500大小
function getW500Image(url = '') {
if (!url) return ''
url = url.replace('original', 'w500')
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
}
// 计算Poster地址
const getPosterUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.poster_path ?? ''
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
})
// 计算backdrop地址
const getBackdropUrl: Ref<string> = computed(() => {
const url = mediaDetail.value.backdrop_path ?? ''
// 使用图片缓存
if (globalSettings.GLOBAL_IMAGE_CACHE)
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
return url
})
// 获取发行国家名称
const getProductionCountries = computed(() => {
return mediaDetail.value.production_countries?.map(country => country.name)
})
// 获取发行公司名称
const getProductionCompanies = computed(() => {
return mediaDetail.value.production_companies?.map(company => company.name)
})
// 计算存在状态的颜色
function getExistColor(season: number) {
const state = seasonsNotExisted.value[season]
if (!state) return 'success'
if (state === 1) return 'warning'
else if (state === 2) return 'error'
else return 'success'
}
// 计算存在状态的文本
function getExistText(season: number) {
const state = seasonsNotExisted.value[season]
if (!state) return t('media.status.inLibrary')
if (state === 1) return t('media.status.partiallyMissing')
else if (state === 2) return t('media.status.missing')
else return t('media.status.inLibrary')
}
// 计算订阅图标
const getSubscribeIcon = computed(() => {
if (isSubscribed.value) return 'mdi-heart'
else return 'mdi-heart-outline'
})
// 计算订阅按钮颜色
const getSubscribeColor = computed(() => {
if (isSubscribed.value) return 'error'
else return 'warning'
})
// 使用、拼装数组为字符串
function joinArray(arr: string[]) {
return arr.join('、')
}
// 开始搜索
function handleSearch() {
const keyword = getMediaId()
router.push({
path: '/resource',
query: {
keyword,
type: mediaDetail.value.type,
area: searchType.value,
title: mediaDetail.value.title,
year: mediaDetail.value.year,
season: mediaDetail.value.season,
sites: selectedSites.value.join(','),
},
})
}
// 跳转播放页面
async function handlePlay() {
// 获取播放链接地址
try {
const result: { [key: string]: any } = await api.get(`mediaserver/play/${existsItemId.value}`)
if (result?.success) {
// 打开链接地址
setTimeout(() => {
window.open(result.data.url, '_blank')
}, 100)
} else {
$toast.error(`获取播放链接失败:${result.message}`)
}
} catch (error) {
console.error(error)
}
}
async function queryDefaultSubscribeConfig() {
// 非管理员不显示
if (!userStore.superUser) return false
try {
let subscribe_config_url = ''
if (mediaProps.type === '电影') subscribe_config_url = 'system/setting/DefaultMovieSubscribeConfig'
else subscribe_config_url = 'system/setting/DefaultTvSubscribeConfig'
const result: { [key: string]: any } = await api.get(subscribe_config_url)
if (result.data?.value) return result.data.value.show_edit_dialog
} catch (error) {
console.log(error)
}
return false
}
// 删除订阅处理
function onSubscribeEditRemove() {
subscribeEditDialog.value = false
if (mediaDetail.value.type === '电影') checkMovieSubscribed()
else checkSeasonsSubscribed()
}
// 点击搜索
async function clickSearch(type: string) {
searchType.value = type
if (allSites.value?.length == 0) {
await querySites()
await querySelectedSites()
}
if (allSites.value?.length > 0) {
chooseSiteDialog.value = true
} else {
handleSearch()
}
}
// 搜索多站点
function searchSites(sites: number[]) {
chooseSiteDialog.value = false
selectedSites.value = sites
handleSearch()
}
onBeforeMount(() => {
getMediaDetail()
})
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id" class="max-w-8xl mx-auto px-4">
<template v-if="(getBackdropUrl || getPosterUrl) && isNonTransparentTheme">
<div class="vue-media-back absolute left-0 top-0 w-full h-96">
<VImg class="h-96" position="top" :src="getBackdropUrl || getPosterUrl" cover />
</div>
<div class="vue-media-back absolute left-0 top-0 w-full h-96" />
</template>
<div class="media-page">
<div class="media-header">
<div class="media-poster">
<VImg
:src="getW500Image(mediaDetail.poster_path)"
cover
class="object-cover aspect-w-2 aspect-h-3 ring-1 ring-gray-500"
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</div>
<div class="media-title">
<div v-if="existsItemId" class="media-status">
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap transition !no-underline bg-green-500 bg-opacity-80 border border-green-500 !text-green-100 hover:bg-green-500 hover:bg-opacity-100 false overflow-hidden"
>
<div class="relative z-20 flex items-center false">
<span>{{ t('media.status.inLibrary') }}</span>
</div>
</span>
</div>
<h1 class="d-flex flex-column flex-lg-row align-baseline justify-center justify-lg-start">
<div class="align-self-center align-self-lg-end">
{{ mediaDetail.title }}
</div>
<div v-if="mediaDetail.year" class="text-lg align-self-center align-self-lg-end">
{{ mediaDetail.year }}
</div>
</h1>
<span class="media-attributes">
<span v-if="mediaDetail.runtime || mediaDetail.episode_run_time[0]"
>{{ mediaDetail.runtime || mediaDetail.episode_run_time[0] }} {{ t('media.minutes') }}</span
>
<span v-if="(mediaDetail.runtime || mediaDetail.episode_run_time[0]) && mediaDetail.genres" class="mx-1">
|
</span>
<span v-if="mediaDetail.genres">{{ getGenresName(mediaDetail.genres || []) }}</span>
</span>
</div>
<div class="media-actions">
<VBtn
v-if="
(mediaDetail.tmdb_id || mediaDetail.douban_id || mediaDetail.bangumi_id) &&
hasPermission({ is_superuser: userStore.superUser, ...userStore.permissions }, 'search')
"
variant="tonal"
color="info"
class="mb-2"
>
<template #prepend>
<VIcon icon="mdi-magnify" />
</template>
{{ t('media.actions.searchResource') }}
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem @click="clickSearch('title')">
<VListItemTitle>{{ t('media.search.byTitle') }}</VListItemTitle>
</VListItem>
<VListItem @click="clickSearch('imdb')">
<VListItemTitle>{{ t('media.search.byImdb') }}</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</VBtn>
<VBtn
v-if="mediaDetail.type === '电影' || mediaDetail.douban_id || mediaDetail.bangumi_id"
class="ms-2 mb-2"
:color="getSubscribeColor"
variant="tonal"
@click="handleSubscribe(0)"
>
<template #prepend>
<VIcon :icon="getSubscribeIcon" />
</template>
{{ isSubscribed ? t('media.status.subscribed') : t('media.actions.subscribe') }}
</VBtn>
<VBtn v-if="existsItemId" class="ms-2 mb-2" variant="tonal" @click="handlePlay()">
<template #prepend>
<VIcon icon="mdi-play" />
</template>
{{ t('media.actions.playOnline') }}
</VBtn>
</div>
</div>
<div class="media-overview">
<div class="media-overview-left">
<div v-if="mediaDetail.tagline" class="tagline">
{{ mediaDetail.tagline }}
</div>
<h2 v-if="mediaDetail.overview">{{ t('media.overview') }}</h2>
<p>{{ mediaDetail.overview }}</p>
<ul v-if="mediaDetail.tmdb_id" class="media-crew">
<li v-for="director in mediaDetail.directors" :key="director.id">
<span>{{ director.job }}</span>
<RouterLink :to="`/person?personid=${director.id}`" class="crew-name" target="_blank">
{{ director.name }}
</RouterLink>
</li>
</ul>
<ul v-if="!mediaDetail.tmdb_id && mediaDetail.douban_id" class="media-crew">
<li v-for="director in mediaDetail.directors" :key="director.id">
<span>{{ joinArray(director.roles) }}</span>
<a class="crew-name" :href="`${director.url}`" target="_blank">{{ director.name }}</a>
</li>
</ul>
<div class="mt-6">
<a
v-if="mediaDetail.tmdb_id"
class="mb-2 mr-2 inline-flex last:mr-0"
:href="getTheMovieDbLink()"
target="_blank"
>
<div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
>
<VIcon icon="mdi-link" />
<span class="ms-1">TheMovieDb</span>
</div>
</a>
<a
v-if="mediaDetail.douban_id"
class="mb-2 mr-2 inline-flex last:mr-0"
:href="getDoubanLink()"
target="_blank"
>
<div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
>
<VIcon icon="mdi-link" />
<span class="ms-1">豆瓣</span>
</div>
</a>
<a v-if="mediaDetail.imdb_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getImdbLink()" target="_blank">
<div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
>
<VIcon icon="mdi-link" />
<span class="ms-1">IMDb</span>
</div>
</a>
<a v-if="mediaDetail.tvdb_id" class="mb-2 mr-2 inline-flex last:mr-0" :href="getTvdbLink()" target="_blank">
<div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
>
<VIcon icon="mdi-link" />
<span class="ms-1">TheTvDb</span>
</div>
</a>
<a
v-if="mediaDetail.bangumi_id"
class="mb-2 mr-2 inline-flex last:mr-0"
:href="getBangumiLink()"
target="_blank"
>
<div
class="inline-flex cursor-pointer items-center rounded-full bg-gray-600 px-2 py-1 text-sm text-gray-200 ring-1 ring-gray-500 transition hover:bg-gray-700"
>
<VIcon icon="mdi-link" />
<span class="ms-1">Bangumi</span>
</div>
</a>
</div>
<h2 v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="py-4">{{ t('media.seasons') }}</h2>
<div v-if="mediaDetail.type === '电视剧' && mediaDetail.tmdb_id" class="flex w-full flex-col space-y-2">
<VExpansionPanels>
<VExpansionPanel
v-for="season in getMediaSeasons"
:key="season.season_number"
@group:selected="loadSeasonEpisodes(season.season_number || 0)"
>
<VExpansionPanelTitle>
<template #default>
<div class="flex flex-row items-center justify-between">
<span class="font-weight-bold">{{
t('media.seasonNumber', { number: season.season_number })
}}</span>
<VChip size="small" class="ms-1">
{{ t('media.episodeCount', { count: season.episode_count }) }}
</VChip>
<div class="absolute right-12">
<VChip v-if="seasonsNotExisted" :color="getExistColor(season.season_number || 0)" flat>
{{ getExistText(season.season_number || 0) }}
</VChip>
<IconBtn
class="ms-1"
:color="seasonsSubscribed[season.season_number || 0] ? 'error' : 'warning'"
variant="text"
@click.stop="handleSubscribe(season.season_number)"
>
<VIcon
:icon="seasonsSubscribed[season.season_number || 0] ? 'mdi-heart' : 'mdi-heart-outline'"
/>
</IconBtn>
</div>
</div>
</template>
</VExpansionPanelTitle>
<VExpansionPanelText>
<template #default>
<LoadingBanner v-if="!seasonEpisodesInfo[season.season_number || 0]" class="mt-3" />
<div class="flex flex-col justify-center divide-y divide-gray-700">
<div
v-for="episode in seasonEpisodesInfo[season.season_number || 0]"
:key="episode.episode_number"
class="flex flex-col space-y-4 py-4 xl:flex-row xl:space-y-4 xl:space-x-4"
>
<div class="flex-1">
<div class="flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-2">
<h3 class="text-lg">{{ episode.episode_number }} - {{ episode.name }}</h3>
<div class="flex items-center space-x-2">
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full whitespace-nowrap cursor-default bg-gray-700 !text-gray-300"
>
{{ episode.air_date }}
</span>
</div>
<VIcon
v-if="
existsEpisodes[season.season_number || 0] &&
existsEpisodes[season.season_number || 0].includes(episode.episode_number || 0)
"
color="success"
icon="mdi-check-circle"
class="ms-2"
size="small"
/>
</div>
<p>{{ episode.overview }}</p>
</div>
<VImg
cover
class="rounded-lg"
max-width="15rem"
:src="getEpisodeImage(episode.still_path || '')"
alt=""
/>
</div>
</div>
</template>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</div>
</div>
<div v-if="mediaDetail.tmdb_id" class="media-overview-right">
<div class="media-facts">
<div v-if="mediaDetail.vote_average" class="media-ratings">
<VRating v-model="mediaDetail.vote_average" density="compact" length="10" class="ma-2" readonly />
</div>
<div v-if="mediaDetail.tmdb_id" class="media-fact">
<span>ID</span>
<span class="media-fact-value">{{ mediaDetail.tmdb_id }}</span>
</div>
<div v-if="mediaDetail.original_title || mediaDetail.original_name" class="media-fact">
<span>{{ t('media.info.originalTitle') }}</span>
<span class="media-fact-value">{{ mediaDetail.original_title || mediaDetail.original_name }}</span>
</div>
<div v-if="mediaDetail.status" class="media-fact">
<span>{{ t('media.info.status') }}</span>
<span class="media-fact-value">{{ mediaDetail.status }}</span>
</div>
<div v-if="mediaDetail.release_date || mediaDetail.first_air_date" class="media-fact">
<span>{{ t('media.info.releaseDate') }}</span>
<span class="media-fact-value">
<span class="flex items-center justify-end">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
class="h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
<span class="ml-1.5">{{ mediaDetail.release_date || mediaDetail.first_air_date }}</span>
</span>
</span>
</div>
<div v-if="mediaDetail.original_language" class="media-fact">
<span>{{ t('media.info.originalLanguage') }}</span>
<span class="media-fact-value">{{ mediaDetail.original_language }}</span>
</div>
<div v-if="mediaDetail.production_countries" class="media-fact">
<span>{{ t('media.info.productionCountries') }}</span>
<span class="media-fact-value">
<span
v-for="country in getProductionCountries"
:key="country"
class="flex items-center justify-end text-end"
>
{{ country }}
</span>
</span>
</div>
<div class="media-fact border-b-0">
<span>{{ t('media.info.productionCompanies') }}</span>
<span class="media-fact-value text-end">
<span v-for="company in getProductionCompanies" :key="company" class="block">{{ company }}</span>
</span>
</div>
</div>
</div>
<div v-else-if="mediaDetail.douban_id" class="media-overview-right">
<div class="media-facts">
<div v-if="mediaDetail.vote_average" class="media-ratings">
<VRating v-model="mediaDetail.vote_average" density="compact" length="10" class="ma-2" readonly />
</div>
<div v-if="mediaDetail.douban_id" class="media-fact">
<span>{{ t('media.info.doubanId') }}</span>
<span class="media-fact-value">{{ mediaDetail.douban_id }}</span>
</div>
<div v-if="mediaDetail.original_title" class="media-fact">
<span>{{ t('media.info.originalTitle') }}</span>
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
</div>
<div v-if="mediaDetail.release_date" class="media-fact">
<span>{{ t('media.info.releaseDate') }}</span>
<span class="media-fact-value">
{{ mediaDetail.release_date }}
</span>
</div>
<div v-if="mediaDetail.production_countries" class="media-fact border-b-0">
<span>{{ t('media.info.productionCountries') }}</span>
<span class="media-fact-value">
<span
v-for="country in getProductionCountries"
:key="country"
class="flex items-center justify-end text-end"
>
{{ country }}
</span>
</span>
</div>
</div>
</div>
<div v-else-if="mediaDetail.bangumi_id" class="media-overview-right">
<div class="media-facts">
<div v-if="mediaDetail.vote_average" class="media-ratings">
<VRating v-model="mediaDetail.vote_average" density="compact" length="10" class="ma-2" readonly />
</div>
<div v-if="mediaDetail.bangumi_id" class="media-fact">
<span>ID</span>
<span class="media-fact-value">{{ mediaDetail.bangumi_id }}</span>
</div>
<div v-if="mediaDetail.original_title" class="media-fact">
<span>{{ t('media.info.originalTitle') }}</span>
<span class="media-fact-value">{{ mediaDetail.original_title }}</span>
</div>
<div v-if="mediaDetail.release_date" class="media-fact border-b-0">
<span>{{ t('media.info.releaseDate') }}</span>
<span class="media-fact-value">
{{ mediaDetail.release_date }}
</span>
</div>
</div>
</div>
</div>
<div v-if="mediaDetail.tmdb_id">
<PersonCardSlideView
:apipath="`tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}`"
:linkurl="`/credits/tmdb/credits/${mediaDetail.tmdb_id}/${mediaProps.type}?title=${t(
'media.castAndCrew',
)}&type=tmdb`"
:title="t('media.castAndCrew')"
type="tmdb"
/>
</div>
<div v-else-if="mediaDetail.douban_id">
<PersonCardSlideView
:apipath="`douban/credits/${mediaDetail.douban_id}/${mediaProps.type}`"
:linkurl="`/credits/douban/credits/${mediaDetail.douban_id}/${mediaProps.type}?title=${t(
'media.castAndCrew',
)}&type=douban`"
:title="t('media.castAndCrew')"
type="douban"
/>
</div>
<div v-else-if="mediaDetail.bangumi_id">
<PersonCardSlideView
:apipath="`bangumi/credits/${mediaDetail.bangumi_id}`"
:linkurl="`/credits/bangumi/credits/${mediaDetail.bangumi_id}?title=${t('media.castAndCrew')}&type=bangumi`"
:title="t('media.castAndCrew')"
type="bangumi"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView
:apipath="`tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}`"
:linkurl="`/browse/tmdb/recommend/${mediaDetail.tmdb_id}/${mediaProps.type}?title=${t(
'media.recommendations',
)}`"
:title="t('media.recommendations')"
/>
</div>
<div v-else-if="mediaDetail.douban_id">
<MediaCardSlideView
:apipath="`douban/recommend/${mediaDetail.douban_id}/${mediaProps.type}`"
:linkurl="`/browse/douban/recommend/${mediaDetail.douban_id}/${mediaProps.type}?title=${t(
'media.recommendations',
)}`"
:title="t('media.recommendations')"
/>
</div>
<div v-else-if="mediaDetail.bangumi_id">
<MediaCardSlideView
:apipath="`bangumi/recommend/${mediaDetail.bangumi_id}`"
:linkurl="`/browse/bangumi/recommend/${mediaDetail.bangumi_id}?title=${t('media.recommendations')}`"
:title="t('media.recommendations')"
/>
</div>
<div v-if="mediaDetail.tmdb_id">
<MediaCardSlideView
:apipath="`tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}`"
:linkurl="`/browse/tmdb/similar/${mediaDetail.tmdb_id}/${mediaProps.type}?title=${t('media.similar')}`"
:title="t('media.similar')"
/>
</div>
</div>
</div>
<NoDataFound
v-if="!mediaDetail.tmdb_id && !mediaDetail.douban_id && !mediaDetail.bangumi_id && isRefreshed"
error-code="500"
:error-title="t('media.error.title')"
:error-description="t('media.error.noMediaInfo')"
/>
<!-- 订阅编辑弹窗 -->
<SubscribeEditDialog
v-if="subscribeEditDialog"
v-model="subscribeEditDialog"
:subid="subscribeId"
@close="subscribeEditDialog = false"
@save="subscribeEditDialog = false"
@remove="onSubscribeEditRemove"
/>
<!-- 站点选择对话框 -->
<SearchSiteDialog
v-if="chooseSiteDialog"
v-model="chooseSiteDialog"
:sites="allSites"
:selected="selectedSites"
@search="searchSites"
@close="chooseSiteDialog = false"
/>
</template>
<style lang="scss" scoped>
.vue-media-back {
background-image: linear-gradient(
180deg,
rgba(var(--v-theme-background), 0) 50%,
rgba(var(--v-theme-background), 1) 100%
),
linear-gradient(90deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%),
linear-gradient(270deg, rgba(var(--v-theme-background), 0) 50%, rgba(var(--v-theme-background), 1) 100%);
margin-block-start: calc(-70px - env(safe-area-inset-top));
}
.media-page {
position: relative;
background-position: 50%;
background-size: cover;
margin-block-start: calc(-4rem - env(safe-area-inset-top));
margin-inline: -1rem;
padding-block-start: calc(4rem + env(safe-area-inset-top));
padding-inline: 1rem;
}
.media-header {
display: flex;
flex-direction: column;
align-items: center;
padding-block-start: 1rem;
}
@media (width >= 1280px) {
.media-header {
flex-direction: row;
align-items: flex-end;
}
}
.media-overview {
display: flex;
flex-direction: column;
padding-block: 2rem 1rem;
}
@media (width >= 1024px) {
.media-overview {
flex-direction: row;
}
}
.media-poster {
overflow: hidden;
border-radius: 0.25rem;
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
inline-size: 8rem;
--tw-shadow: 0 1px 3px 0 rgba(0, 0, 0, 10%), 0 1px 2px -1px rgba(0, 0, 0, 10%);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
}
@media (width >= 1280px) {
.media-poster {
inline-size: 13rem;
margin-inline-end: 1rem;
}
}
@media (width >= 768px) {
.media-poster {
border-radius: 0.5rem;
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
inline-size: 11rem;
--tw-shadow: 0 25px 50px -12px rgba(0, 0, 0, 25%);
--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);
}
}
.media-title {
display: flex;
flex: 1 1 0%;
flex-direction: column;
margin-block-start: 1rem;
text-align: center;
}
@media (width >= 1280px) {
.media-title {
margin-block-start: 0;
margin-inline-end: 1rem;
text-align: start;
}
}
.media-title > h1 {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
}
@media (width >= 1280px) {
.media-title > h1 {
font-size: 2.25rem;
line-height: 2.5rem;
}
}
ul.media-crew {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-block-start: 1.5rem;
}
@media (width >= 640px) {
ul.media-crew {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
ul.media-crew > li {
display: flex;
flex-direction: column;
font-weight: 700;
grid-column: span 1 / span 1;
}
a.crew-name {
font-weight: 400;
}
.media-status {
margin-block-end: 0.5rem;
}
.media-attributes {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-block-start: 0.25rem;
}
@media (width >= 1280px) {
.media-attributes {
justify-content: flex-start;
font-size: 1rem;
line-height: 1.5rem;
margin-block-start: 0;
}
}
@media (width >= 640px) {
.media-attributes {
font-size: 0.875rem;
line-height: 1.25rem;
}
}
.media-actions {
position: relative;
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-block-start: 1rem;
}
@media (width >= 1280px) {
.media-actions {
margin-block-start: 0;
}
}
@media (width >= 640px) {
.media-actions {
flex-wrap: nowrap;
justify-content: flex-end;
}
}
.media-overview-left {
flex: 1 1 0%;
}
@media (width >= 1024px) {
.media-overview-left {
margin-inline-end: 2rem;
}
}
.media-overview-right {
inline-size: 100%;
margin-block-start: 2rem;
}
@media (width >= 1024px) {
.media-overview-right {
inline-size: 20rem;
margin-block-start: 0;
}
}
.media-facts {
border-width: 1px;
border-color: rgb(55 65 81 / var(--tw-border-opacity));
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 700;
line-height: 1.25rem;
--tw-border-opacity: 1;
--tw-bg-opacity: 1;
--tw-text-opacity: 1;
}
.media-ratings {
display: flex;
align-items: center;
justify-content: center;
border-color: rgb(55 65 81 / var(--tw-border-opacity));
border-block-end-width: 1px;
font-weight: 500;
padding-block: 0.5rem;
padding-inline: 1rem;
--tw-border-opacity: 1;
}
.media-fact {
display: flex;
justify-content: space-between;
border-color: rgb(55 65 81 / var(--tw-border-opacity));
border-block-end-width: 1px;
padding-block: 0.5rem;
padding-inline: 1rem;
--tw-border-opacity: 1;
}
.media-overview h2 {
font-size: 1.25rem;
font-weight: 700;
line-height: 1.75rem;
}
@media (width >= 640px) {
.media-overview h2 {
font-size: 1.5rem;
line-height: 2rem;
}
}
.tagline {
font-size: 1.25rem;
font-style: italic;
line-height: 1.75rem;
margin-block-end: 1rem;
}
@media (width >= 1024px) {
.tagline {
font-size: 1.5rem;
line-height: 2rem;
}
}
</style>