mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-04 14:01:27 +08:00
Add media recommendations dashboard and episode groups
This commit is contained in:
@@ -24,6 +24,7 @@ const asyncDashboardOptions = {
|
||||
const builtInDashboardComponentLoaders: Record<string, DashboardComponentLoader> = {
|
||||
storage: () => import('@/views/dashboard/AnalyticsStorage.vue'),
|
||||
mediaStatistic: () => import('@/views/dashboard/AnalyticsMediaStatistic.vue'),
|
||||
mediaRecommend: () => import('@/views/dashboard/MediaRecommend.vue'),
|
||||
weeklyOverview: () => import('@/views/dashboard/AnalyticsWeeklyOverview.vue'),
|
||||
speed: () => import('@/views/dashboard/AnalyticsSpeed.vue'),
|
||||
scheduler: () => import('@/views/dashboard/AnalyticsScheduler.vue'),
|
||||
@@ -68,6 +69,7 @@ function createAsyncDashboardComponent(id: string) {
|
||||
// 内置仪表盘按需加载,关闭的卡片不再挤进 dashboard 首屏 chunk。
|
||||
const AnalyticsStorage = createAsyncDashboardComponent('storage')
|
||||
const AnalyticsMediaStatistic = createAsyncDashboardComponent('mediaStatistic')
|
||||
const MediaRecommend = createAsyncDashboardComponent('mediaRecommend')
|
||||
const AnalyticsWeeklyOverview = createAsyncDashboardComponent('weeklyOverview')
|
||||
const AnalyticsSpeed = createAsyncDashboardComponent('speed')
|
||||
const AnalyticsScheduler = createAsyncDashboardComponent('scheduler')
|
||||
@@ -202,6 +204,7 @@ onUnmounted(() => {
|
||||
<!-- 系统内置的仪表板 -->
|
||||
<AnalyticsStorage v-if="config?.id === 'storage'" />
|
||||
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
|
||||
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
|
||||
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
|
||||
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||
|
||||
@@ -60,6 +60,9 @@ const trailingSpaceWidth = computed(() => {
|
||||
return Math.max(totalContentWidth.value - leadingSpaceWidth.value - visibleItemsWidth.value, 0)
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取容器宽度不可用时的兜底视口宽度。
|
||||
*/
|
||||
function getFallbackViewportWidth() {
|
||||
if (typeof window === 'undefined') {
|
||||
return itemStep.value * Math.max(props.overscanItems, 1)
|
||||
@@ -69,6 +72,9 @@ function getFallbackViewportWidth() {
|
||||
return Math.max(window.innerWidth, itemStep.value * Math.max(props.overscanItems, 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析虚拟列表项的稳定 key。
|
||||
*/
|
||||
function resolveItemKey(item: any, index: number) {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, startIndex.value + index)
|
||||
@@ -77,6 +83,9 @@ function resolveItemKey(item: any, index: number) {
|
||||
return startIndex.value + index
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置滚动状态指示计时器。
|
||||
*/
|
||||
function resetScrollIndicatorTimer() {
|
||||
isScrolling.value = true
|
||||
if (scrollTimeout) {
|
||||
@@ -88,6 +97,9 @@ function resetScrollIndicatorTimer() {
|
||||
}, scrollTimeoutDuration)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前滚动位置更新虚拟渲染范围。
|
||||
*/
|
||||
function updateVisibleRange() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) {
|
||||
@@ -113,6 +125,9 @@ function updateVisibleRange() {
|
||||
endIndex.value = Math.max(firstVisible + 1, lastVisible)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步左右导航按钮的可用状态。
|
||||
*/
|
||||
function updateDisabledState() {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -130,11 +145,17 @@ function updateDisabledState() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步虚拟列表布局与导航状态。
|
||||
*/
|
||||
function syncLayoutState() {
|
||||
updateVisibleRange()
|
||||
updateDisabledState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前可视范围向左或向右滚动一屏。
|
||||
*/
|
||||
function slideNext(next: boolean) {
|
||||
const element = slideContentRef.value
|
||||
if (!element) return
|
||||
@@ -159,6 +180,9 @@ function slideNext(next: boolean) {
|
||||
resetScrollIndicatorTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理内容滚动并刷新滚动指示状态。
|
||||
*/
|
||||
function handleContentScroll() {
|
||||
syncLayoutState()
|
||||
resetScrollIndicatorTimer()
|
||||
@@ -257,28 +281,20 @@ watch(
|
||||
<VBtn
|
||||
v-show="disabled !== 0 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-left"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-left"
|
||||
color="white"
|
||||
@click.stop="slideNext(false)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
|
||||
<VBtn
|
||||
v-show="disabled !== 2 && disabled !== 3 && !isTouch"
|
||||
class="nav-button nav-button-right"
|
||||
variant="text"
|
||||
icon
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
icon="mdi-chevron-right"
|
||||
color="white"
|
||||
@click.stop="slideNext(true)"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z" />
|
||||
</svg>
|
||||
</VBtn>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -402,17 +418,18 @@ watch(
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 14%);
|
||||
border-radius: 50%;
|
||||
backdrop-filter: blur(8px);
|
||||
background-color: rgba(var(--v-theme-background), 0.3);
|
||||
block-size: 36px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 8%);
|
||||
background: rgba(8, 18, 28, 52%) !important;
|
||||
block-size: 40px;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 22%);
|
||||
color: rgb(255, 255, 255);
|
||||
cursor: pointer;
|
||||
inline-size: 36px;
|
||||
inline-size: 40px;
|
||||
inset-block-start: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 10%);
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
@@ -421,21 +438,22 @@ watch(
|
||||
box-shadow 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
|
||||
svg {
|
||||
block-size: 22px;
|
||||
fill: currentcolor;
|
||||
filter: none;
|
||||
inline-size: 22px;
|
||||
opacity: 0.7;
|
||||
transition: all 0.3s ease;
|
||||
:deep(.v-icon) {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 55%));
|
||||
font-size: 28px;
|
||||
opacity: 1;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
border-color: rgba(255, 255, 255, 28%);
|
||||
background: rgba(8, 18, 28, 68%) !important;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 28%);
|
||||
color: rgb(255, 255, 255);
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
:deep(.v-icon) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -960,6 +960,13 @@ export default {
|
||||
storage: 'Storage',
|
||||
storageSummary: '{available} available / {total} total',
|
||||
mediaStatistic: 'Media Statistics',
|
||||
recommendedMedia: 'Recommended Media',
|
||||
selectRecommendSource: 'Select recommendation source',
|
||||
previousRecommend: 'Previous recommendation',
|
||||
nextRecommend: 'Next recommendation',
|
||||
showRecommend: 'Show recommendation {index}',
|
||||
noRecommendations: 'No recommendations from this source',
|
||||
recommendLoadFailed: 'Failed to load recommendations',
|
||||
weeklyOverview: 'Import Statistics',
|
||||
realTimeSpeed: 'Real-time Speed',
|
||||
scheduler: 'Background Tasks',
|
||||
@@ -1047,6 +1054,14 @@ export default {
|
||||
specials: 'Specials',
|
||||
seasonNumber: 'Season {number}',
|
||||
episodeCount: '{count} Episodes',
|
||||
episodeGroups: {
|
||||
select: 'Select Episode Group',
|
||||
default: 'Default',
|
||||
summary: '{seasons} Seasons · {episodes} Episodes',
|
||||
current: 'Current: {name} · {seasons} Seasons · {episodes} Episodes',
|
||||
previous: 'View previous episode groups',
|
||||
next: 'View more episode groups',
|
||||
},
|
||||
actions: {
|
||||
searchResource: 'Search Resource',
|
||||
searchSubtitle: 'Search Subtitle',
|
||||
|
||||
@@ -952,6 +952,13 @@ export default {
|
||||
storage: '存储空间',
|
||||
storageSummary: '可用 {available} / 总容量 {total}',
|
||||
mediaStatistic: '媒体统计',
|
||||
recommendedMedia: '推荐媒体',
|
||||
selectRecommendSource: '选择推荐媒体来源',
|
||||
previousRecommend: '上一项推荐',
|
||||
nextRecommend: '下一项推荐',
|
||||
showRecommend: '查看第 {index} 项推荐',
|
||||
noRecommendations: '当前来源暂无推荐媒体',
|
||||
recommendLoadFailed: '推荐媒体加载失败',
|
||||
weeklyOverview: '入库统计',
|
||||
realTimeSpeed: '实时速率',
|
||||
scheduler: '后台任务',
|
||||
@@ -1041,6 +1048,14 @@ export default {
|
||||
specials: '特别篇',
|
||||
seasonNumber: '第 {number} 季',
|
||||
episodeCount: '{count}集',
|
||||
episodeGroups: {
|
||||
select: '选择剧集组',
|
||||
default: '默认',
|
||||
summary: '{seasons} 季 · {episodes} 集',
|
||||
current: '当前:{name} · {seasons} 季 · {episodes} 集',
|
||||
previous: '查看上一组剧集组',
|
||||
next: '查看更多剧集组',
|
||||
},
|
||||
actions: {
|
||||
searchResource: '搜索资源',
|
||||
searchSubtitle: '搜索字幕',
|
||||
|
||||
@@ -952,6 +952,13 @@ export default {
|
||||
storage: '存儲空間',
|
||||
storageSummary: '可用 {available} / 總容量 {total}',
|
||||
mediaStatistic: '媒體統計',
|
||||
recommendedMedia: '推薦媒體',
|
||||
selectRecommendSource: '選擇推薦媒體來源',
|
||||
previousRecommend: '上一項推薦',
|
||||
nextRecommend: '下一項推薦',
|
||||
showRecommend: '查看第 {index} 項推薦',
|
||||
noRecommendations: '當前來源暫無推薦媒體',
|
||||
recommendLoadFailed: '推薦媒體加載失敗',
|
||||
weeklyOverview: '入庫統計',
|
||||
realTimeSpeed: '實時速率',
|
||||
scheduler: '後台任務',
|
||||
@@ -1041,6 +1048,14 @@ export default {
|
||||
specials: '特別篇',
|
||||
seasonNumber: '第 {number} 季',
|
||||
episodeCount: '{count}集',
|
||||
episodeGroups: {
|
||||
select: '選擇劇集組',
|
||||
default: '預設',
|
||||
summary: '{seasons} 季 · {episodes} 集',
|
||||
current: '目前:{name} · {seasons} 季 · {episodes} 集',
|
||||
previous: '查看上一組劇集組',
|
||||
next: '查看更多劇集組',
|
||||
},
|
||||
actions: {
|
||||
searchResource: '搜索資源',
|
||||
searchSubtitle: '搜索字幕',
|
||||
|
||||
@@ -25,9 +25,11 @@ const { t } = useI18n()
|
||||
const { appMode } = usePWA()
|
||||
const display = useDisplay()
|
||||
const userStore = useUserStore()
|
||||
const canAdmin = computed(() =>
|
||||
hasPermission(buildUserPermissionContext(userStore.superUser, userStore.permissions), 'admin'),
|
||||
const userPermissionContext = computed(() =>
|
||||
buildUserPermissionContext(userStore.superUser, userStore.permissions),
|
||||
)
|
||||
const canAdmin = computed(() => hasPermission(userPermissionContext.value, 'admin'))
|
||||
const canDiscovery = computed(() => hasPermission(userPermissionContext.value, 'discovery'))
|
||||
|
||||
// 路由
|
||||
const route = useRoute()
|
||||
@@ -76,6 +78,7 @@ const DASHBOARD_DESKTOP_DEFAULT_LAYOUT: DashboardGridLayoutConfig = {
|
||||
cpu: { x: 4, y: 22, w: 4, h: DASHBOARD_RESOURCE_CHART_ROWS },
|
||||
quickActions: { x: 8, y: 22, w: 4, h: 5 },
|
||||
systemInfo: { x: 8, y: 27, w: 4, h: 6 },
|
||||
mediaRecommend: { x: 0, y: 33, w: 12, h: 23 },
|
||||
}
|
||||
|
||||
// 单个设备档位的仪表盘配置,将布局与显示项绑定到同一份持久化数据。
|
||||
@@ -171,6 +174,15 @@ const dashboardConfigs = ref<DashboardItem[]>([
|
||||
rows: 7,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
id: 'mediaRecommend',
|
||||
name: t('dashboard.recommendedMedia'),
|
||||
key: '',
|
||||
attrs: {},
|
||||
cols: { cols: 12 },
|
||||
rows: 23,
|
||||
elements: [],
|
||||
},
|
||||
{
|
||||
id: 'weeklyOverview',
|
||||
name: t('dashboard.weeklyOverview'),
|
||||
@@ -287,7 +299,12 @@ const pluginDashboardRefreshStatus = ref<{ [key: string]: boolean }>({})
|
||||
// 当前启用且可渲染的仪表板 Grid 项。
|
||||
const dashboardGridItems = computed<DashboardGridItem[]>(() =>
|
||||
dashboardConfigs.value
|
||||
.filter(item => enableConfig.value[buildPluginDashboardId(item.id, item.key)] && item.cols)
|
||||
.filter(
|
||||
item =>
|
||||
enableConfig.value[buildPluginDashboardId(item.id, item.key)] &&
|
||||
item.cols &&
|
||||
(item.id !== 'mediaRecommend' || canDiscovery.value),
|
||||
)
|
||||
.map(item => {
|
||||
const id = buildPluginDashboardId(item.id, item.key)
|
||||
|
||||
@@ -378,6 +395,7 @@ function clampGridNumber(value: unknown, min: number, max: number, fallback: num
|
||||
function getDefaultDashboardEnableConfig(): DashboardEnableConfig {
|
||||
return {
|
||||
mediaStatistic: true,
|
||||
mediaRecommend: true,
|
||||
scheduler: true,
|
||||
speed: true,
|
||||
storage: true,
|
||||
|
||||
@@ -11,6 +11,11 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { getRecommendTabs } from '@/router/i18n-menu'
|
||||
import { useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import {
|
||||
createBuiltInRecommendSources,
|
||||
mergeExtraRecommendSources,
|
||||
type RecommendViewSource,
|
||||
} from '@/utils/recommendSources'
|
||||
|
||||
const ContentToggleSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/ContentToggleSettingsDialog.vue'))
|
||||
|
||||
@@ -63,86 +68,7 @@ function openRecommendSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
const viewList = reactive<{ apipath: string; linkurl: string; title: string; type: string }[]>([
|
||||
{
|
||||
apipath: 'recommend/tmdb_trending',
|
||||
linkurl: '/browse/recommend/tmdb_trending?title=' + t('recommend.trendingNow'),
|
||||
title: t('recommend.trendingNow'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_showing',
|
||||
linkurl: '/browse/recommend/douban_showing?title=' + t('recommend.nowShowing'),
|
||||
title: t('recommend.nowShowing'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/bangumi_calendar',
|
||||
linkurl: '/browse/recommend/bangumi_calendar?title=' + t('recommend.bangumiDaily'),
|
||||
title: t('recommend.bangumiDaily'),
|
||||
type: t('recommend.categoryAnime'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/tmdb_movies',
|
||||
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),
|
||||
title: t('recommend.tmdbHotMovies'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
|
||||
linkurl: '/browse/recommend/tmdb_tvs??with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),
|
||||
title: t('recommend.tmdbHotTVShows'),
|
||||
type: t('recommend.categoryTV'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movie_hot',
|
||||
linkurl: '/browse/recommend/douban_movie_hot?title=' + t('recommend.doubanHotMovies'),
|
||||
title: t('recommend.doubanHotMovies'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_hot',
|
||||
linkurl: '/browse/recommend/douban_tv_hot?title=' + t('recommend.doubanHotTVShows'),
|
||||
title: t('recommend.doubanHotTVShows'),
|
||||
type: t('recommend.categoryTV'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_animation',
|
||||
linkurl: '/browse/recommend/douban_tv_animation?title=' + t('recommend.doubanHotAnime'),
|
||||
title: t('recommend.doubanHotAnime'),
|
||||
type: t('recommend.categoryAnime'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movies',
|
||||
linkurl: '/browse/recommend/douban_movies?title=' + t('recommend.doubanNewMovies'),
|
||||
title: t('recommend.doubanNewMovies'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tvs',
|
||||
linkurl: '/browse/recommend/douban_tvs?title=' + t('recommend.doubanNewTVShows'),
|
||||
title: t('recommend.doubanNewTVShows'),
|
||||
type: t('recommend.categoryTV'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movie_top250',
|
||||
linkurl: '/browse/recommend/douban_movie_top250?title=' + t('recommend.doubanTop250'),
|
||||
title: t('recommend.doubanTop250'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_weekly_chinese',
|
||||
linkurl: '/browse/recommend/douban_tv_weekly_chinese?title=' + t('recommend.doubanChineseTVRankings'),
|
||||
title: t('recommend.doubanChineseTVRankings'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_weekly_global',
|
||||
linkurl: '/browse/recommend/douban_tv_weekly_global?title=' + t('recommend.doubanGlobalTVRankings'),
|
||||
title: t('recommend.doubanGlobalTVRankings'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
])
|
||||
const viewList = reactive<RecommendViewSource[]>(createBuiltInRecommendSources(t))
|
||||
|
||||
// 计算当前分类下显示的视图
|
||||
const filteredViews = computed(() => {
|
||||
@@ -175,20 +101,7 @@ const extraRecommendSources = ref<RecommendSource[]>([])
|
||||
async function loadExtraRecommendSources() {
|
||||
try {
|
||||
extraRecommendSources.value = await api.get('recommend/source')
|
||||
if (extraRecommendSources.value.length > 0) {
|
||||
extraRecommendSources.value.map(source => {
|
||||
if (!viewList.some(item => item.apipath === source.api_path)) {
|
||||
const querySeparator = source.api_path.includes('?') ? '&' : '?'
|
||||
const linkUrl = `/browse/${source.api_path}${querySeparator}title=${encodeURIComponent(source.name)}`
|
||||
viewList.push({
|
||||
apipath: source.api_path,
|
||||
linkurl: linkUrl,
|
||||
title: source.name,
|
||||
type: source.type,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
mergeExtraRecommendSources(viewList, extraRecommendSources.value)
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
110
src/utils/recommendSources.ts
Normal file
110
src/utils/recommendSources.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { RecommendSource } from '@/api/types'
|
||||
|
||||
export interface RecommendViewSource {
|
||||
apipath: string
|
||||
linkurl: string
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
|
||||
type Translate = (key: string) => string
|
||||
|
||||
/** 创建与推荐页面一致的内置媒体来源列表。 */
|
||||
export function createBuiltInRecommendSources(t: Translate): RecommendViewSource[] {
|
||||
return [
|
||||
{
|
||||
apipath: 'recommend/tmdb_trending',
|
||||
linkurl: '/browse/recommend/tmdb_trending?title=' + t('recommend.trendingNow'),
|
||||
title: t('recommend.trendingNow'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_showing',
|
||||
linkurl: '/browse/recommend/douban_showing?title=' + t('recommend.nowShowing'),
|
||||
title: t('recommend.nowShowing'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/bangumi_calendar',
|
||||
linkurl: '/browse/recommend/bangumi_calendar?title=' + t('recommend.bangumiDaily'),
|
||||
title: t('recommend.bangumiDaily'),
|
||||
type: t('recommend.categoryAnime'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/tmdb_movies',
|
||||
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),
|
||||
title: t('recommend.tmdbHotMovies'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/tmdb_tvs?with_original_language=zh|en|ja|ko',
|
||||
linkurl:
|
||||
'/browse/recommend/tmdb_tvs?with_original_language=zh|en|ja|ko&title=' + t('recommend.tmdbHotTVShows'),
|
||||
title: t('recommend.tmdbHotTVShows'),
|
||||
type: t('recommend.categoryTV'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movie_hot',
|
||||
linkurl: '/browse/recommend/douban_movie_hot?title=' + t('recommend.doubanHotMovies'),
|
||||
title: t('recommend.doubanHotMovies'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_hot',
|
||||
linkurl: '/browse/recommend/douban_tv_hot?title=' + t('recommend.doubanHotTVShows'),
|
||||
title: t('recommend.doubanHotTVShows'),
|
||||
type: t('recommend.categoryTV'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_animation',
|
||||
linkurl: '/browse/recommend/douban_tv_animation?title=' + t('recommend.doubanHotAnime'),
|
||||
title: t('recommend.doubanHotAnime'),
|
||||
type: t('recommend.categoryAnime'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movies',
|
||||
linkurl: '/browse/recommend/douban_movies?title=' + t('recommend.doubanNewMovies'),
|
||||
title: t('recommend.doubanNewMovies'),
|
||||
type: t('recommend.categoryMovie'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tvs',
|
||||
linkurl: '/browse/recommend/douban_tvs?title=' + t('recommend.doubanNewTVShows'),
|
||||
title: t('recommend.doubanNewTVShows'),
|
||||
type: t('recommend.categoryTV'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_movie_top250',
|
||||
linkurl: '/browse/recommend/douban_movie_top250?title=' + t('recommend.doubanTop250'),
|
||||
title: t('recommend.doubanTop250'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_weekly_chinese',
|
||||
linkurl: '/browse/recommend/douban_tv_weekly_chinese?title=' + t('recommend.doubanChineseTVRankings'),
|
||||
title: t('recommend.doubanChineseTVRankings'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/douban_tv_weekly_global',
|
||||
linkurl: '/browse/recommend/douban_tv_weekly_global?title=' + t('recommend.doubanGlobalTVRankings'),
|
||||
title: t('recommend.doubanGlobalTVRankings'),
|
||||
type: t('recommend.categoryRankings'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** 把后端扩展媒体来源合并到现有来源列表,并保持已有顺序。 */
|
||||
export function mergeExtraRecommendSources(target: RecommendViewSource[], sources: RecommendSource[]) {
|
||||
sources.forEach(source => {
|
||||
if (target.some(item => item.apipath === source.api_path)) return
|
||||
|
||||
const querySeparator = source.api_path.includes('?') ? '&' : '?'
|
||||
target.push({
|
||||
apipath: source.api_path,
|
||||
linkurl: `/browse/${source.api_path}${querySeparator}title=${encodeURIComponent(source.name)}`,
|
||||
title: source.name,
|
||||
type: source.type,
|
||||
})
|
||||
})
|
||||
}
|
||||
640
src/views/dashboard/MediaRecommend.vue
Normal file
640
src/views/dashboard/MediaRecommend.vue
Normal file
@@ -0,0 +1,640 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaInfo, RecommendSource } from '@/api/types'
|
||||
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
||||
import router from '@/router'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
import {
|
||||
createBuiltInRecommendSources,
|
||||
mergeExtraRecommendSources,
|
||||
type RecommendViewSource,
|
||||
} from '@/utils/recommendSources'
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const globalSettingsStore = useGlobalSettingsStore()
|
||||
const RECOMMEND_SOURCE_STORAGE_KEY = 'MP_DASHBOARD_RECOMMEND_SOURCE'
|
||||
const RECOMMEND_SLIDE_COUNT = 5
|
||||
const RECOMMEND_AUTOPLAY_INTERVAL = 8000
|
||||
|
||||
const sources = ref<RecommendViewSource[]>(createBuiltInRecommendSources(t))
|
||||
const selectedSourcePath = ref(localStorage.getItem(RECOMMEND_SOURCE_STORAGE_KEY) || sources.value[0].apipath)
|
||||
const mediaItems = shallowRef<MediaInfo[]>([])
|
||||
const mediaCache = new Map<string, MediaInfo[]>()
|
||||
const activeIndex = ref(0)
|
||||
const loading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const touchStartX = ref<number | null>(null)
|
||||
let requestId = 0
|
||||
let autoplayTimer: number | null = null
|
||||
|
||||
const selectedSource = computed(
|
||||
() => sources.value.find(source => source.apipath === selectedSourcePath.value) ?? sources.value[0],
|
||||
)
|
||||
|
||||
const activeMedia = computed(() => mediaItems.value[activeIndex.value])
|
||||
|
||||
/** 返回与媒体来源匹配的 Material Design 图标。 */
|
||||
function getSourceIcon(source: RecommendViewSource) {
|
||||
if (source.apipath.includes('bangumi')) return 'mdi-television-play'
|
||||
if (source.apipath.includes('douban')) return 'mdi-star-circle-outline'
|
||||
if (source.apipath.includes('tmdb')) return 'mdi-movie-open-star-outline'
|
||||
return 'mdi-compass-outline'
|
||||
}
|
||||
|
||||
/** 将不同接口包装格式归一化为媒体数组。 */
|
||||
function normalizeMediaResponse(response: unknown): MediaInfo[] {
|
||||
if (Array.isArray(response)) return response
|
||||
if (!response || typeof response !== 'object') return []
|
||||
|
||||
const data = (response as { data?: unknown }).data
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && typeof data === 'object' && Array.isArray((data as { list?: unknown }).list)) {
|
||||
return (data as { list: MediaInfo[] }).list
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/** 判断媒体是否具备可展示图片和可进入详情页的标识。 */
|
||||
function isUsableMedia(item: MediaInfo) {
|
||||
const hasMediaId = Boolean(
|
||||
item.tmdb_id ||
|
||||
item.douban_id ||
|
||||
item.bangumi_id ||
|
||||
item.collection_id ||
|
||||
(item.mediaid_prefix && item.media_id),
|
||||
)
|
||||
return Boolean(item.title && (item.backdrop_path || item.poster_path) && hasMediaId)
|
||||
}
|
||||
|
||||
/** 构造轮播项稳定键,兼容合集与扩展媒体来源。 */
|
||||
function getMediaKey(item: MediaInfo) {
|
||||
if (item.collection_id) return `collection:${item.collection_id}`
|
||||
return getMediaSubscribeId(item)
|
||||
}
|
||||
|
||||
/** 加载指定推荐来源,并缓存当前会话已获取的数据。 */
|
||||
async function loadMedia(sourcePath = selectedSourcePath.value) {
|
||||
const cachedItems = mediaCache.get(sourcePath)
|
||||
if (cachedItems) {
|
||||
mediaItems.value = cachedItems
|
||||
activeIndex.value = 0
|
||||
loading.value = false
|
||||
loadFailed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const currentRequestId = ++requestId
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const response = await api.get(sourcePath)
|
||||
if (currentRequestId !== requestId) return
|
||||
|
||||
const items = normalizeMediaResponse(response).filter(isUsableMedia).slice(0, RECOMMEND_SLIDE_COUNT)
|
||||
mediaCache.set(sourcePath, items)
|
||||
mediaItems.value = items
|
||||
activeIndex.value = 0
|
||||
} catch (error) {
|
||||
if (currentRequestId !== requestId) return
|
||||
console.error(error)
|
||||
mediaItems.value = []
|
||||
loadFailed.value = true
|
||||
} finally {
|
||||
if (currentRequestId === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载后端扩展的推荐来源。 */
|
||||
async function loadExtraSources() {
|
||||
try {
|
||||
const extraSources: RecommendSource[] = (await api.get('recommend/source')) ?? []
|
||||
mergeExtraRecommendSources(sources.value, extraSources)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换当前推荐来源并持久化用户选择。 */
|
||||
function selectSource(source: RecommendViewSource) {
|
||||
if (selectedSourcePath.value === source.apipath) return
|
||||
|
||||
selectedSourcePath.value = source.apipath
|
||||
localStorage.setItem(RECOMMEND_SOURCE_STORAGE_KEY, source.apipath)
|
||||
void loadMedia(source.apipath)
|
||||
}
|
||||
|
||||
/** 返回经过全局图片缓存与代理设置处理的背景图地址。 */
|
||||
function getBackdropUrl(item: MediaInfo) {
|
||||
const sourceUrl = (item.backdrop_path || item.poster_path || noImage).replace('original', 'w1280')
|
||||
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
/** 组合年份、媒体类型与风格标签。 */
|
||||
function getMediaMeta(item: MediaInfo) {
|
||||
return [item.year, item.type, ...(item.genres?.slice(0, 3) ?? [])].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** 打开当前媒体的详情页面。 */
|
||||
function goToMediaDetail() {
|
||||
const item = activeMedia.value
|
||||
if (!item) return
|
||||
|
||||
if (item.collection_id) {
|
||||
void router.push({ path: `/browse/tmdb/collection/${item.collection_id}`, query: { title: item.title } })
|
||||
return
|
||||
}
|
||||
|
||||
void router.push({
|
||||
path: '/media',
|
||||
query: {
|
||||
mediaid: getMediaSubscribeId(item),
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
year: item.year,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 切换到上一项媒体。 */
|
||||
function showPrevious() {
|
||||
if (mediaItems.value.length < 2) return
|
||||
activeIndex.value = (activeIndex.value - 1 + mediaItems.value.length) % mediaItems.value.length
|
||||
}
|
||||
|
||||
/** 切换到下一项媒体。 */
|
||||
function showNext() {
|
||||
if (mediaItems.value.length < 2) return
|
||||
activeIndex.value = (activeIndex.value + 1) % mediaItems.value.length
|
||||
}
|
||||
|
||||
/** 跳转到指定轮播项。 */
|
||||
function showSlide(index: number) {
|
||||
activeIndex.value = index
|
||||
}
|
||||
|
||||
/** 记录触摸起点,供移动端判断横向滑动。 */
|
||||
function handleTouchStart(event: TouchEvent) {
|
||||
touchStartX.value = event.changedTouches[0]?.clientX ?? null
|
||||
}
|
||||
|
||||
/** 根据触摸位移切换移动端轮播项。 */
|
||||
function handleTouchEnd(event: TouchEvent) {
|
||||
if (touchStartX.value === null) return
|
||||
|
||||
const deltaX = (event.changedTouches[0]?.clientX ?? touchStartX.value) - touchStartX.value
|
||||
touchStartX.value = null
|
||||
if (Math.abs(deltaX) < 48) return
|
||||
if (deltaX > 0) showPrevious()
|
||||
else showNext()
|
||||
}
|
||||
|
||||
/** 启动轮播自动播放,系统减少动态效果时保持静态。 */
|
||||
function startAutoplay() {
|
||||
stopAutoplay()
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
autoplayTimer = window.setInterval(() => {
|
||||
if (!isPaused.value) showNext()
|
||||
}, RECOMMEND_AUTOPLAY_INTERVAL)
|
||||
}
|
||||
|
||||
/** 停止轮播自动播放并清理定时器。 */
|
||||
function stopAutoplay() {
|
||||
if (!autoplayTimer) return
|
||||
window.clearInterval(autoplayTimer)
|
||||
autoplayTimer = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadExtraSources(), loadMedia()])
|
||||
if (!sources.value.some(source => source.apipath === selectedSourcePath.value)) {
|
||||
selectSource(sources.value[0])
|
||||
}
|
||||
startAutoplay()
|
||||
})
|
||||
|
||||
onActivated(startAutoplay)
|
||||
onDeactivated(stopAutoplay)
|
||||
onBeforeUnmount(stopAutoplay)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard
|
||||
class="dashboard-recommend dashboard-grid-fill dashboard-grid-no-drag"
|
||||
:class="{ 'is-loading': loading }"
|
||||
@mouseenter="isPaused = true"
|
||||
@mouseleave="isPaused = false"
|
||||
@focusin="isPaused = true"
|
||||
@focusout="isPaused = false"
|
||||
@touchstart.passive="handleTouchStart"
|
||||
@touchend.passive="handleTouchEnd"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<VSkeletonLoader class="dashboard-recommend-skeleton" type="image" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="mediaItems.length">
|
||||
<VWindow v-model="activeIndex" class="dashboard-recommend-window" :touch="false">
|
||||
<VWindowItem v-for="item in mediaItems" :key="getMediaKey(item)" class="dashboard-recommend-slide">
|
||||
<VImg
|
||||
:src="getBackdropUrl(item)"
|
||||
:alt="item.title"
|
||||
class="dashboard-recommend-image"
|
||||
cover
|
||||
eager
|
||||
@click="goToMediaDetail"
|
||||
/>
|
||||
</VWindowItem>
|
||||
</VWindow>
|
||||
|
||||
<div class="dashboard-recommend-shade" aria-hidden="true"></div>
|
||||
|
||||
<div class="dashboard-recommend-topbar">
|
||||
<div class="dashboard-recommend-label">
|
||||
<VIcon icon="mdi-creation" size="20" color="primary" />
|
||||
<span>{{ t('dashboard.recommendedMedia') }}</span>
|
||||
</div>
|
||||
|
||||
<VMenu location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<VBtn
|
||||
v-bind="menuProps"
|
||||
class="dashboard-recommend-source"
|
||||
variant="tonal"
|
||||
color="white"
|
||||
rounded="pill"
|
||||
append-icon="mdi-chevron-down"
|
||||
>
|
||||
<VIcon :icon="getSourceIcon(selectedSource)" color="primary" start />
|
||||
<span>{{ selectedSource.title }}</span>
|
||||
</VBtn>
|
||||
</template>
|
||||
<VList density="compact" max-height="360" :aria-label="t('dashboard.selectRecommendSource')">
|
||||
<VListItem
|
||||
v-for="source in sources"
|
||||
:key="source.apipath"
|
||||
:active="source.apipath === selectedSourcePath"
|
||||
:prepend-icon="getSourceIcon(source)"
|
||||
:title="source.title"
|
||||
@click="selectSource(source)"
|
||||
/>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="dashboard-recommend-content"
|
||||
role="link"
|
||||
tabindex="0"
|
||||
@click="goToMediaDetail"
|
||||
@keydown.enter="goToMediaDetail"
|
||||
>
|
||||
<h2 class="dashboard-recommend-title">{{ activeMedia?.title }}</h2>
|
||||
<div class="dashboard-recommend-meta">{{ activeMedia ? getMediaMeta(activeMedia) : '' }}</div>
|
||||
<p v-if="activeMedia?.overview" class="dashboard-recommend-overview">{{ activeMedia.overview }}</p>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
class="dashboard-recommend-detail"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
rounded="pill"
|
||||
append-icon="mdi-chevron-right"
|
||||
@click.stop="goToMediaDetail"
|
||||
>
|
||||
{{ t('common.viewDetails') }}
|
||||
</VBtn>
|
||||
|
||||
<VBtn
|
||||
v-if="mediaItems.length > 1"
|
||||
class="dashboard-recommend-arrow dashboard-recommend-arrow--previous"
|
||||
icon="mdi-chevron-left"
|
||||
variant="tonal"
|
||||
color="white"
|
||||
:aria-label="t('dashboard.previousRecommend')"
|
||||
@click.stop="showPrevious"
|
||||
/>
|
||||
|
||||
<div v-if="mediaItems.length > 1" class="dashboard-recommend-pagination">
|
||||
<button
|
||||
v-for="(_item, index) in mediaItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="dashboard-recommend-page"
|
||||
:class="{ 'is-active': activeIndex === index }"
|
||||
:aria-label="t('dashboard.showRecommend', { index: index + 1 })"
|
||||
:aria-current="activeIndex === index ? 'true' : undefined"
|
||||
@click.stop="showSlide(index)"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
v-if="mediaItems.length > 1"
|
||||
class="dashboard-recommend-arrow dashboard-recommend-arrow--next"
|
||||
icon="mdi-chevron-right"
|
||||
variant="tonal"
|
||||
color="white"
|
||||
:aria-label="t('dashboard.nextRecommend')"
|
||||
@click.stop="showNext"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-else class="dashboard-recommend-empty">
|
||||
<VIcon icon="mdi-image-off-outline" size="38" />
|
||||
<span>{{ loadFailed ? t('dashboard.recommendLoadFailed') : t('dashboard.noRecommendations') }}</span>
|
||||
<VBtn v-if="loadFailed" variant="tonal" size="small" @click="loadMedia()">{{ t('common.retry') }}</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-recommend {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
block-size: 100%;
|
||||
min-block-size: 520px;
|
||||
background: rgb(8, 18, 28);
|
||||
color: white;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.dashboard-recommend-window,
|
||||
.dashboard-recommend-slide,
|
||||
.dashboard-recommend-image,
|
||||
.dashboard-recommend-skeleton {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.dashboard-recommend-window {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.dashboard-recommend-image {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-recommend-image :deep(.v-img__img) {
|
||||
object-position: center top;
|
||||
}
|
||||
|
||||
.dashboard-recommend-shade {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(3, 8, 14, 0.08) 0%, rgba(5, 12, 19, 0.04) 42%, rgba(5, 14, 22, 0.72) 100%),
|
||||
linear-gradient(90deg, rgba(5, 14, 22, 0.42) 0%, rgba(5, 14, 22, 0.12) 46%, transparent 68%);
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-recommend-topbar {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
inset-block-start: 1.25rem;
|
||||
inset-inline: 1.4rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 18, 28, 0.56);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-source {
|
||||
max-inline-size: min(320px, 45vw);
|
||||
background: rgba(8, 18, 28, 0.55) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.dashboard-recommend-source span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-recommend-content {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
max-inline-size: min(640px, 56%);
|
||||
cursor: pointer;
|
||||
inset-block-end: 4.8rem;
|
||||
inset-inline-start: 1.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dashboard-recommend-content:focus-visible {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 3px rgba(var(--v-theme-primary), 0.48);
|
||||
}
|
||||
|
||||
.dashboard-recommend-title {
|
||||
margin: 0;
|
||||
color: rgb(255, 255, 255);
|
||||
font-size: clamp(1.6rem, 2.5vw, 2.45rem);
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
text-shadow: 0 3px 20px rgba(0, 0, 0, 0.82), 0 1px 2px rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.dashboard-recommend-meta {
|
||||
margin-block-start: 0.55rem;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-overview {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 0.75rem 0 0;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 0.85rem;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.65;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dashboard-recommend-detail {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
min-inline-size: 148px;
|
||||
inset-block-end: 4.9rem;
|
||||
inset-inline-end: 1.9rem;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.dashboard-recommend-arrow {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(8, 18, 28, 0.52) !important;
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
inset-block-end: 1.15rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-arrow--previous {
|
||||
inset-inline-start: 1.4rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-arrow--next {
|
||||
inset-inline-end: 1.4rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-pagination {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
inset-block-end: 1.8rem;
|
||||
inset-inline: 25%;
|
||||
}
|
||||
|
||||
.dashboard-recommend-page {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.24);
|
||||
block-size: 4px;
|
||||
cursor: pointer;
|
||||
inline-size: 54px;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s ease, inline-size 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-recommend-page.is-active {
|
||||
background: rgb(var(--v-theme-primary));
|
||||
inline-size: 72px;
|
||||
}
|
||||
|
||||
.dashboard-recommend-empty {
|
||||
display: flex;
|
||||
block-size: 100%;
|
||||
min-block-size: 520px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 740px) {
|
||||
.dashboard-recommend {
|
||||
min-block-size: 460px;
|
||||
}
|
||||
|
||||
.dashboard-recommend-topbar {
|
||||
inset-block-start: 0.85rem;
|
||||
inset-inline: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-label {
|
||||
padding: 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-source {
|
||||
max-inline-size: 50vw;
|
||||
min-inline-size: 0;
|
||||
padding-inline: 0.7rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-content {
|
||||
max-inline-size: calc(100% - 1.7rem);
|
||||
inset-block-end: 7.5rem;
|
||||
inset-inline: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-title {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-overview {
|
||||
font-size: 0.8rem;
|
||||
-webkit-line-clamp: 2;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard-recommend-detail {
|
||||
min-inline-size: 124px;
|
||||
inset-block-end: 3.8rem;
|
||||
inset-inline-end: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-arrow {
|
||||
block-size: 36px;
|
||||
inline-size: 36px;
|
||||
inset-block-end: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-arrow--previous {
|
||||
inset-inline-start: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-arrow--next {
|
||||
inset-inline-end: 0.85rem;
|
||||
}
|
||||
|
||||
.dashboard-recommend-pagination {
|
||||
gap: 0.3rem;
|
||||
inset-block-end: 1.75rem;
|
||||
inset-inline: 22%;
|
||||
}
|
||||
|
||||
.dashboard-recommend-page {
|
||||
inline-size: 22px;
|
||||
}
|
||||
|
||||
.dashboard-recommend-page.is-active {
|
||||
inline-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.dashboard-recommend-label span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-recommend-label {
|
||||
block-size: 40px;
|
||||
inline-size: 40px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dashboard-recommend-source {
|
||||
max-inline-size: 68vw;
|
||||
}
|
||||
|
||||
.dashboard-recommend-detail {
|
||||
inset-inline: 0.85rem;
|
||||
inline-size: calc(100% - 1.7rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-recommend-page {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@ import { useToast } from 'vue-toastification'
|
||||
import PersonCardSlideView from './PersonCardSlideView.vue'
|
||||
import MediaCardSlideView from './MediaCardSlideView.vue'
|
||||
import api from '@/api'
|
||||
import type { MediaInfo, MediaRelease, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
|
||||
import type { MediaInfo, MediaRelease, MediaSeason, NotExistMediaInfo, Site, Subscribe, TmdbEpisode } from '@/api/types'
|
||||
import NoDataFound from '@/components/states/NoDataFound.vue'
|
||||
import { formatSeasonLabel } from '@/@core/utils/season'
|
||||
import router from '@/router'
|
||||
@@ -93,12 +93,50 @@ interface MediaSearchOptions {
|
||||
episode?: number | null
|
||||
}
|
||||
|
||||
interface EpisodeGroupInfo {
|
||||
id: string
|
||||
name: string
|
||||
group_count: number
|
||||
episode_count: number
|
||||
}
|
||||
|
||||
interface EpisodeGroupOption extends EpisodeGroupInfo {
|
||||
icon: string
|
||||
}
|
||||
|
||||
// 站点选择后待执行的搜索类型
|
||||
const pendingSearchResultType = ref<'torrent' | 'subtitle'>('torrent')
|
||||
|
||||
// 站点选择后待执行的季集参数
|
||||
const pendingSearchOptions = ref<MediaSearchOptions>({})
|
||||
|
||||
// 可用剧集组
|
||||
const episodeGroups = ref<EpisodeGroupInfo[]>([])
|
||||
|
||||
// 当前选中的剧集组,空字符串表示 TMDB 默认排序
|
||||
const selectedEpisodeGroup = ref('')
|
||||
|
||||
// 当前自定义剧集组的季信息
|
||||
const episodeGroupSeasons = ref<MediaSeason[]>([])
|
||||
|
||||
// 剧集组列表加载状态
|
||||
const episodeGroupsLoading = ref(false)
|
||||
|
||||
// 自定义剧集组季信息加载状态
|
||||
const episodeGroupSeasonsLoading = ref(false)
|
||||
|
||||
// 剧集组横向轨道
|
||||
const episodeGroupRail = ref<HTMLElement | null>(null)
|
||||
|
||||
// 剧集组轨道左右滚动状态
|
||||
const canScrollEpisodeGroupsBackward = ref(false)
|
||||
const canScrollEpisodeGroupsForward = ref(false)
|
||||
|
||||
// 防止快速切换剧集组时旧请求覆盖新结果
|
||||
let episodeGroupSeasonRequestId = 0
|
||||
let seasonNotExistsRequestId = 0
|
||||
let episodeExistsRequestId = 0
|
||||
|
||||
// 计算主题是否为透明
|
||||
const isTransparentTheme = computed(() => {
|
||||
return theme.name.value === 'transparent'
|
||||
@@ -165,6 +203,12 @@ async function getMediaDetail() {
|
||||
isRefreshed.value = true
|
||||
if (!mediaDetail.value.tmdb_id && !mediaDetail.value.douban_id && !mediaDetail.value.bangumi_id) return
|
||||
|
||||
selectedEpisodeGroup.value = mediaDetail.value.episode_group || ''
|
||||
if (mediaDetail.value.type === '电视剧' && mediaDetail.value.tmdb_id) {
|
||||
getEpisodeGroups()
|
||||
if (selectedEpisodeGroup.value) loadEpisodeGroupSeasons(selectedEpisodeGroup.value)
|
||||
}
|
||||
|
||||
// 检查存在状态
|
||||
checkExists()
|
||||
if (mediaDetail.value.type === '电视剧') checkSeasonsNotExists()
|
||||
@@ -181,7 +225,7 @@ async function loadSeasonEpisodes(season: number) {
|
||||
// 加载季集信息
|
||||
if (seasonEpisodesInfo.value[season]) return
|
||||
try {
|
||||
const params = mediaDetail.value.episode_group ? { episode_group: mediaDetail.value.episode_group } : undefined
|
||||
const params = selectedEpisodeGroup.value ? { episode_group: selectedEpisodeGroup.value } : undefined
|
||||
const result: TmdbEpisode[] = await api.get(`tmdb/${mediaDetail.value.tmdb_id}/${season}`, params ? { params } : undefined)
|
||||
seasonEpisodesInfo.value[season] = result || []
|
||||
} catch (error) {
|
||||
@@ -193,9 +237,14 @@ async function loadSeasonEpisodes(season: number) {
|
||||
async function loadEpisodeExists() {
|
||||
// 查询季集存在状态
|
||||
if (!isNullOrEmptyObject(existsEpisodes.value)) return
|
||||
const requestId = ++episodeExistsRequestId
|
||||
try {
|
||||
const result: { [key: number]: number[] } = await api.post(`mediaserver/exists_remote`, mediaDetail.value)
|
||||
existsEpisodes.value = result || {}
|
||||
const media = {
|
||||
...mediaDetail.value,
|
||||
episode_group: selectedEpisodeGroup.value || '',
|
||||
}
|
||||
const result: { [key: number]: number[] } = await api.post(`mediaserver/exists_remote`, media)
|
||||
if (requestId === episodeExistsRequestId) existsEpisodes.value = result || {}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -246,9 +295,15 @@ function isSameSubscribeMedia(subscribe: Subscribe) {
|
||||
// 检查所有季的缺失状态
|
||||
async function checkSeasonsNotExists() {
|
||||
if (mediaDetail.value.type !== '电视剧') return
|
||||
const requestId = ++seasonNotExistsRequestId
|
||||
seasonsNotExisted.value = {}
|
||||
try {
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', mediaDetail.value)
|
||||
if (result) {
|
||||
const media = {
|
||||
...mediaDetail.value,
|
||||
episode_group: selectedEpisodeGroup.value || '',
|
||||
}
|
||||
const result: NotExistMediaInfo[] = await api.post('mediaserver/notexists', media)
|
||||
if (requestId === seasonNotExistsRequestId && result) {
|
||||
result.forEach(item => {
|
||||
// 0-已入库 1-部分缺失 2-全部缺失
|
||||
let state = 0
|
||||
@@ -268,16 +323,119 @@ async function checkMovieSubscribed() {
|
||||
isSubscribed.value = await checkSubscribe()
|
||||
}
|
||||
|
||||
// 默认排序的总集数
|
||||
const defaultEpisodeCount = computed(() =>
|
||||
(mediaDetail.value?.season_info ?? []).reduce((total, season) => total + (season.episode_count ?? 0), 0),
|
||||
)
|
||||
|
||||
// 剧集组选项,首项固定为 TMDB 默认排序
|
||||
const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => [
|
||||
{
|
||||
id: '',
|
||||
name: t('media.episodeGroups.default'),
|
||||
group_count: mediaDetail.value?.season_info?.length ?? 0,
|
||||
episode_count: defaultEpisodeCount.value,
|
||||
icon: 'mdi-layers-outline',
|
||||
},
|
||||
...episodeGroups.value.map(group => ({
|
||||
...group,
|
||||
icon: 'mdi-folder-play-outline',
|
||||
})),
|
||||
])
|
||||
|
||||
// 当前选中的剧集组选项
|
||||
const selectedEpisodeGroupOption = computed(
|
||||
() => episodeGroupOptions.value.find(group => group.id === selectedEpisodeGroup.value) ?? episodeGroupOptions.value[0]!,
|
||||
)
|
||||
|
||||
// 季列表,第0季排在最后
|
||||
const getMediaSeasons = computed(() => {
|
||||
if (!mediaDetail.value?.season_info) return []
|
||||
return [...mediaDetail.value.season_info].sort((a, b) => {
|
||||
const seasons = selectedEpisodeGroup.value ? episodeGroupSeasons.value : mediaDetail.value?.season_info
|
||||
if (!seasons) return []
|
||||
return [...seasons].sort((a, b) => {
|
||||
if (a.season_number === 0) return 1
|
||||
if (b.season_number === 0) return -1
|
||||
return (a.season_number || 0) - (b.season_number || 0)
|
||||
})
|
||||
})
|
||||
|
||||
// 查询当前媒体可用的剧集组
|
||||
async function getEpisodeGroups() {
|
||||
if (!mediaDetail.value.tmdb_id) return
|
||||
|
||||
episodeGroupsLoading.value = true
|
||||
try {
|
||||
const result: EpisodeGroupInfo[] = await api.get(`media/groups/${mediaDetail.value.tmdb_id}`)
|
||||
episodeGroups.value = result || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
episodeGroups.value = []
|
||||
} finally {
|
||||
episodeGroupsLoading.value = false
|
||||
nextTick(updateEpisodeGroupScrollState)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询指定剧集组的季信息,并忽略过期响应
|
||||
async function loadEpisodeGroupSeasons(groupId: string) {
|
||||
if (!groupId) {
|
||||
episodeGroupSeasons.value = []
|
||||
episodeGroupSeasonsLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = ++episodeGroupSeasonRequestId
|
||||
episodeGroupSeasonsLoading.value = true
|
||||
try {
|
||||
const result: MediaSeason[] = await api.get(`media/group/seasons/${groupId}`)
|
||||
if (requestId === episodeGroupSeasonRequestId) episodeGroupSeasons.value = result || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (requestId === episodeGroupSeasonRequestId) episodeGroupSeasons.value = []
|
||||
} finally {
|
||||
if (requestId === episodeGroupSeasonRequestId) episodeGroupSeasonsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换详情页当前浏览的剧集组
|
||||
async function setEpisodeGroup(groupId: string) {
|
||||
if (selectedEpisodeGroup.value === groupId) return
|
||||
|
||||
selectedEpisodeGroup.value = groupId
|
||||
seasonEpisodesInfo.value = {}
|
||||
existsEpisodes.value = {}
|
||||
episodeGroupSeasons.value = []
|
||||
episodeGroupSeasonRequestId += 1
|
||||
episodeExistsRequestId += 1
|
||||
|
||||
await Promise.all([loadEpisodeGroupSeasons(groupId), checkSeasonsNotExists()])
|
||||
}
|
||||
|
||||
// 刷新剧集组横向轨道的左右滚动按钮状态
|
||||
function updateEpisodeGroupScrollState() {
|
||||
const rail = episodeGroupRail.value
|
||||
if (!rail) {
|
||||
canScrollEpisodeGroupsBackward.value = false
|
||||
canScrollEpisodeGroupsForward.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const maxScrollLeft = Math.max(rail.scrollWidth - rail.clientWidth, 0)
|
||||
canScrollEpisodeGroupsBackward.value = rail.scrollLeft > 4
|
||||
canScrollEpisodeGroupsForward.value = rail.scrollLeft < maxScrollLeft - 4
|
||||
}
|
||||
|
||||
// 按一屏内可辨识的距离横向滚动剧集组轨道
|
||||
function scrollEpisodeGroups(direction: 'backward' | 'forward') {
|
||||
const rail = episodeGroupRail.value
|
||||
if (!rail) return
|
||||
|
||||
rail.scrollBy({
|
||||
behavior: 'smooth',
|
||||
left: direction === 'backward' ? -Math.max(rail.clientWidth * 0.72, 240) : Math.max(rail.clientWidth * 0.72, 240),
|
||||
})
|
||||
}
|
||||
|
||||
// 检查所有季的订阅状态
|
||||
async function checkSeasonsSubscribed() {
|
||||
if (mediaDetail.value.type !== '电视剧') return
|
||||
@@ -307,6 +465,7 @@ async function checkSeasonsSubscribed() {
|
||||
}
|
||||
}
|
||||
|
||||
// 已订阅季号列表
|
||||
const subscribedSeasonNumbers = computed(() =>
|
||||
Object.entries(seasonsSubscribed.value)
|
||||
.filter(([, subscribed]) => subscribed)
|
||||
@@ -314,8 +473,10 @@ const subscribedSeasonNumbers = computed(() =>
|
||||
.sort((a, b) => a - b),
|
||||
)
|
||||
|
||||
const subscribeSeasonTotal = computed(() => getMediaSeasons.value.length)
|
||||
// 默认季结构中的可订阅季总数
|
||||
const subscribeSeasonTotal = computed(() => mediaDetail.value?.season_info?.length ?? 0)
|
||||
|
||||
// 当前媒体是否已订阅默认季结构中的全部季
|
||||
const isAllSeasonsSubscribed = computed(
|
||||
() =>
|
||||
mediaDetail.value.type === '电视剧' &&
|
||||
@@ -461,6 +622,7 @@ const getSubscribeColor = computed(() => {
|
||||
else return 'warning'
|
||||
})
|
||||
|
||||
// 计算订阅按钮文案
|
||||
const getSubscribeText = computed(() => {
|
||||
if (mediaDetail.value.type === '电视剧') {
|
||||
if (isAllSeasonsSubscribed.value) return t('media.status.allSeasonsSubscribed')
|
||||
@@ -568,6 +730,14 @@ async function handleSubtitleSearch() {
|
||||
onBeforeMount(() => {
|
||||
getMediaDetail()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateEpisodeGroupScrollState)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateEpisodeGroupScrollState)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -761,7 +931,67 @@ onBeforeMount(() => {
|
||||
</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>
|
||||
<div v-if="episodeGroupsLoading || episodeGroupOptions.length > 1" class="episode-group-selector">
|
||||
<div class="episode-group-label">{{ t('media.episodeGroups.select') }}</div>
|
||||
<VProgressLinear v-if="episodeGroupsLoading" color="primary" indeterminate rounded />
|
||||
<template v-else>
|
||||
<div class="episode-group-rail-shell">
|
||||
<button
|
||||
v-if="canScrollEpisodeGroupsBackward"
|
||||
type="button"
|
||||
class="episode-group-nav episode-group-nav--backward"
|
||||
:aria-label="t('media.episodeGroups.previous')"
|
||||
@click="scrollEpisodeGroups('backward')"
|
||||
>
|
||||
<VIcon icon="mdi-chevron-left" />
|
||||
</button>
|
||||
<div ref="episodeGroupRail" class="episode-group-rail" @scroll.passive="updateEpisodeGroupScrollState">
|
||||
<button
|
||||
v-for="group in episodeGroupOptions"
|
||||
:key="group.id || 'default'"
|
||||
type="button"
|
||||
class="episode-group-option"
|
||||
:class="{ 'episode-group-option--active': selectedEpisodeGroup === group.id }"
|
||||
:aria-pressed="selectedEpisodeGroup === group.id"
|
||||
@click="setEpisodeGroup(group.id)"
|
||||
>
|
||||
<VIcon :icon="group.icon" size="small" class="episode-group-option__icon" />
|
||||
<span class="episode-group-option__text">
|
||||
<span class="episode-group-option__title">{{ group.name }}</span>
|
||||
<span class="episode-group-option__meta">
|
||||
{{
|
||||
t('media.episodeGroups.summary', {
|
||||
seasons: group.group_count,
|
||||
episodes: group.episode_count,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="canScrollEpisodeGroupsForward"
|
||||
type="button"
|
||||
class="episode-group-nav episode-group-nav--forward"
|
||||
:aria-label="t('media.episodeGroups.next')"
|
||||
@click="scrollEpisodeGroups('forward')"
|
||||
>
|
||||
<VIcon icon="mdi-chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="episode-group-current">
|
||||
{{
|
||||
t('media.episodeGroups.current', {
|
||||
name: selectedEpisodeGroupOption.name,
|
||||
seasons: selectedEpisodeGroupOption.group_count,
|
||||
episodes: selectedEpisodeGroupOption.episode_count,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<LoadingBanner v-if="episodeGroupSeasonsLoading" class="mt-3" />
|
||||
<VExpansionPanels v-else :key="selectedEpisodeGroup || 'default'">
|
||||
<VExpansionPanel
|
||||
v-for="season in getMediaSeasons"
|
||||
:key="season.season_number"
|
||||
@@ -1273,6 +1503,7 @@ a.crew-name {
|
||||
|
||||
.media-overview-left {
|
||||
flex: 1 1 0%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
@media (width >= 1024px) {
|
||||
@@ -1281,6 +1512,187 @@ a.crew-name {
|
||||
}
|
||||
}
|
||||
|
||||
.episode-group-selector {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-block-end: 0.5rem;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.episode-group-label {
|
||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.episode-group-rail-shell {
|
||||
position: relative;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.episode-group-rail {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-block: 0.125rem 0.375rem;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: inline proximity;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.episode-group-rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.episode-group-option {
|
||||
display: inline-flex;
|
||||
flex: 0 0 12rem;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: var(--app-control-radius);
|
||||
background: rgba(var(--v-theme-surface), 0.72);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
gap: 0.625rem;
|
||||
min-inline-size: 0;
|
||||
padding-block: 0.625rem;
|
||||
padding-inline: 0.75rem;
|
||||
scroll-snap-align: start;
|
||||
text-align: start;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.episode-group-option:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.5);
|
||||
background: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.episode-group-option--active {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background: rgba(var(--v-theme-primary), 0.14);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.episode-group-option:focus-visible,
|
||||
.episode-group-nav:focus-visible {
|
||||
outline: 2px solid rgba(var(--v-theme-primary), 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.episode-group-option__icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.episode-group-option__text {
|
||||
display: grid;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.episode-group-option__title,
|
||||
.episode-group-option__meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.episode-group-option__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.125rem;
|
||||
}
|
||||
|
||||
.episode-group-option__meta {
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.episode-group-option--active .episode-group-option__meta {
|
||||
color: rgba(var(--v-theme-primary), 0.82);
|
||||
}
|
||||
|
||||
.episode-group-nav {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 9999px;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
block-size: 2.5rem;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
inline-size: 2.5rem;
|
||||
inset-block-start: 50%;
|
||||
transform: translateY(-55%);
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.episode-group-nav:hover {
|
||||
border-color: rgba(var(--v-theme-primary), 0.45);
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.episode-group-nav--backward {
|
||||
inset-inline-start: -0.5rem;
|
||||
}
|
||||
|
||||
.episode-group-nav--forward {
|
||||
inset-inline-end: -0.5rem;
|
||||
}
|
||||
|
||||
.episode-group-current {
|
||||
color: rgba(var(--v-theme-on-surface), 0.66);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.media-detail-transparent .episode-group-option {
|
||||
backdrop-filter: blur(var(--transparent-blur-light, 6px));
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-light, 0.2));
|
||||
}
|
||||
|
||||
.media-detail-transparent .episode-group-option:hover {
|
||||
background: rgba(var(--v-theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.media-detail-transparent .episode-group-option--active {
|
||||
background: rgba(var(--v-theme-primary), 0.16);
|
||||
}
|
||||
|
||||
.media-detail-transparent .episode-group-nav {
|
||||
backdrop-filter: blur(var(--transparent-blur, 10px));
|
||||
background: rgba(var(--v-theme-surface), var(--transparent-opacity-heavy, 0.5));
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.episode-group-option {
|
||||
flex-basis: 9.75rem;
|
||||
padding-block: 0.5625rem;
|
||||
padding-inline: 0.625rem;
|
||||
}
|
||||
|
||||
.episode-group-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.episode-group-current {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.episode-group-rail {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.media-overview-right {
|
||||
inline-size: 100%;
|
||||
margin-block-start: 2rem;
|
||||
|
||||
Reference in New Issue
Block a user