mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
添加动画
This commit is contained in:
@@ -26,6 +26,8 @@ function changeTheme() {
|
|||||||
globalTheme.name.value = nextTheme
|
globalTheme.name.value = nextTheme
|
||||||
savedTheme.value = nextTheme
|
savedTheme.value = nextTheme
|
||||||
localStorage.setItem('theme', nextTheme)
|
localStorage.setItem('theme', nextTheme)
|
||||||
|
|
||||||
|
themeTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update icon if theme is changed from other sources
|
// Update icon if theme is changed from other sources
|
||||||
@@ -40,6 +42,75 @@ watch(
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
globalTheme.name.value = savedTheme.value
|
globalTheme.name.value = savedTheme.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function hasScrollbar(el?: Element | null) {
|
||||||
|
if (!el || el.nodeType !== Node.ELEMENT_NODE)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(el)
|
||||||
|
return style.overflowY === 'scroll' || (style.overflowY === 'auto' && el.scrollHeight > el.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeTransition() {
|
||||||
|
const x = performance.now()
|
||||||
|
for (let i = 0; i++ < 1e7; (i << 9) & ((9 % 9) * 9 + 9));
|
||||||
|
const cost = performance.now() - x
|
||||||
|
if (cost > 10)
|
||||||
|
return
|
||||||
|
|
||||||
|
const el: HTMLElement = document.querySelector('[data-v-app]')!
|
||||||
|
const children = el.querySelectorAll('*') as NodeListOf<HTMLElement>
|
||||||
|
|
||||||
|
children.forEach((el) => {
|
||||||
|
if (hasScrollbar(el)) {
|
||||||
|
el.dataset.scrollX = String(el.scrollLeft)
|
||||||
|
el.dataset.scrollY = String(el.scrollTop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const copy = el.cloneNode(true) as HTMLElement
|
||||||
|
copy.classList.add('app-copy')
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
copy.style.top = `${rect.top}px`
|
||||||
|
copy.style.left = `${rect.left}px`
|
||||||
|
copy.style.width = `${rect.width}px`
|
||||||
|
copy.style.height = `${rect.height}px`
|
||||||
|
|
||||||
|
const targetEl = document.activeElement as HTMLElement
|
||||||
|
const targetRect = targetEl.getBoundingClientRect()
|
||||||
|
const left = targetRect.left + targetRect.width / 2 + window.scrollX
|
||||||
|
const top = targetRect.top + targetRect.height / 2 + window.scrollY
|
||||||
|
el.style.setProperty('--clip-pos', `${left}px ${top}px`)
|
||||||
|
el.style.removeProperty('--clip-size')
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
el.classList.add('app-transition')
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.setProperty('--clip-size', `${Math.hypot(window.innerWidth, window.innerHeight)}px`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
document.body.append(copy)
|
||||||
|
; (copy.querySelectorAll('[data-scroll-x], [data-scroll-y]') as NodeListOf<HTMLElement>).forEach((el) => {
|
||||||
|
el.scrollLeft = +el.dataset.scrollX!
|
||||||
|
el.scrollTop = +el.dataset.scrollY!
|
||||||
|
})
|
||||||
|
|
||||||
|
function onTransitionend(e: TransitionEvent) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
copy.remove()
|
||||||
|
el.removeEventListener('transitionend', onTransitionend)
|
||||||
|
el.removeEventListener('transitioncancel', onTransitionend)
|
||||||
|
el.classList.remove('app-transition')
|
||||||
|
el.style.removeProperty('--clip-size')
|
||||||
|
el.style.removeProperty('--clip-pos')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
el.addEventListener('transitionend', onTransitionend)
|
||||||
|
el.addEventListener('transitioncancel', onTransitionend)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -47,3 +118,19 @@ onMounted(() => {
|
|||||||
<VIcon :icon="props.themes[currentThemeIndex].icon" />
|
<VIcon :icon="props.themes[currentThemeIndex].icon" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="sass">
|
||||||
|
// Theme transition
|
||||||
|
.app-copy
|
||||||
|
position: fixed !important
|
||||||
|
z-index: -1 !important
|
||||||
|
pointer-events: none !important
|
||||||
|
contain: size style !important
|
||||||
|
overflow: clip !important
|
||||||
|
|
||||||
|
.app-transition
|
||||||
|
--clip-size: 0
|
||||||
|
--clip-pos: 0 0
|
||||||
|
clip-path: circle(var(--clip-size) at var(--clip-pos))
|
||||||
|
transition: clip-path .35s ease-out
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Transition } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
import VerticalNav from '@layouts/components/VerticalNav.vue'
|
||||||
|
|
||||||
@@ -50,7 +51,9 @@ export default defineComponent({
|
|||||||
const main = h(
|
const main = h(
|
||||||
'main',
|
'main',
|
||||||
{ class: 'layout-page-content' },
|
{ class: 'layout-page-content' },
|
||||||
h('div', { class: 'page-content-container' }, slots.default?.()),
|
h(Transition, { name: 'fade-slide', mode: 'out-in', appear: true },
|
||||||
|
h('section', { class: 'page-content-container' }, slots.default?.()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 👉 Footer
|
// 👉 Footer
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
min-height: calc(100% + env(safe-area-inset-top));
|
min-height: calc(100% + env(safe-area-inset-top));
|
||||||
background: rgb(var(--v-theme-background));
|
//background: rgb(var(--v-theme-background));
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: rgb(var(--v-theme-background));
|
//background: rgb(var(--v-theme-background));
|
||||||
overscroll-behavior-y: contain;
|
overscroll-behavior-y: contain;
|
||||||
--webkit-overflow-scrolling: touch;
|
--webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { PropType } from 'vue'
|
import type { PropType, Ref } from 'vue'
|
||||||
import { useToast } from 'vue-toast-notification'
|
import { useToast } from 'vue-toast-notification'
|
||||||
import { formatSeason } from '@/@core/utils/formatters'
|
import { formatSeason } from '@/@core/utils/formatters'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
import { doneNProgress, startNProgress } from '@/api/nprogress'
|
||||||
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
import type { MediaInfo, NotExistMediaInfo, Subscribe, TmdbSeason } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import noImage from '@images/no-image.jpeg'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -392,13 +393,16 @@ const seasonsHeaders = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// 计算图片地址
|
// 计算图片地址
|
||||||
function getImgUrl(url: string) {
|
const getImgUrl: Ref<string> = computed(() => {
|
||||||
|
if (imageLoadError.value)
|
||||||
|
return noImage
|
||||||
|
const url = props.media?.poster_path || noImage
|
||||||
// 如果地址中包含douban则使用中转代理
|
// 如果地址中包含douban则使用中转代理
|
||||||
if (url.includes('doubanio.com'))
|
if (url.includes('doubanio.com'))
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
|
return `${import.meta.env.VITE_API_BASE_URL}douban/img/${encodeURIComponent(url)}`
|
||||||
|
|
||||||
return url
|
return url
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -408,15 +412,24 @@ function getImgUrl(url: string) {
|
|||||||
v-bind="hover.props"
|
v-bind="hover.props"
|
||||||
:height="props.height"
|
:height="props.height"
|
||||||
:width="props.width"
|
:width="props.width"
|
||||||
|
:loading="!isImageLoaded"
|
||||||
class="outline-none shadow ring-gray-500"
|
class="outline-none shadow ring-gray-500"
|
||||||
:class="{
|
:class="{
|
||||||
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
'transition transform-cpu duration-300 scale-105 shadow-lg': hover.isHovering,
|
||||||
'ring-1': isImageLoaded,
|
'ring-1': isImageLoaded,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<template #loader="{ isActive }">
|
||||||
|
<v-progress-linear
|
||||||
|
:active="isActive"
|
||||||
|
color="deep-purple"
|
||||||
|
height="4"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<VImg
|
<VImg
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
:src="getImgUrl(props.media?.poster_path || '')"
|
:src="getImgUrl"
|
||||||
class="object-cover aspect-w-2 aspect-h-3"
|
class="object-cover aspect-w-2 aspect-h-3"
|
||||||
:class="hover.isHovering ? 'on-hover' : ''"
|
:class="hover.isHovering ? 'on-hover' : ''"
|
||||||
cover
|
cover
|
||||||
@@ -424,12 +437,16 @@ function getImgUrl(url: string) {
|
|||||||
@error="imageLoadError = true"
|
@error="imageLoadError = true"
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="relative animate-pulse bg-gray-300 w-full">
|
<div class="d-flex align-center justify-center fill-height">
|
||||||
<div class="w-full h-full" />
|
<v-progress-circular
|
||||||
|
color="grey-lighten-4"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- 类型角标 -->
|
<!-- 类型角标 -->
|
||||||
<VChip
|
<VChip
|
||||||
|
v-show="isImageLoaded"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
:class="getChipColor(props.media?.type || '')"
|
:class="getChipColor(props.media?.type || '')"
|
||||||
@@ -441,7 +458,7 @@ function getImgUrl(url: string) {
|
|||||||
<ExistIcon v-if="isExists" />
|
<ExistIcon v-if="isExists" />
|
||||||
<!-- 评分角标 -->
|
<!-- 评分角标 -->
|
||||||
<VChip
|
<VChip
|
||||||
v-if="props.media?.vote_average && !isExists"
|
v-if="isImageLoaded && props.media?.vote_average && !isExists"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
size="small"
|
size="small"
|
||||||
:class="getChipColor('rating')"
|
:class="getChipColor('rating')"
|
||||||
|
|||||||
@@ -50,3 +50,37 @@
|
|||||||
max-height: calc(100% - env(safe-area-inset-top));
|
max-height: calc(100% - env(safe-area-inset-top));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* router view transition fade-slide */
|
||||||
|
.fade-slide-leave-active,
|
||||||
|
.fade-slide-enter-active {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* router view transition fade-slide */
|
||||||
|
.fade-slide-leave-active,
|
||||||
|
.fade-slide-enter-active {
|
||||||
|
transition: all 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-45px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(45px);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user