472 lines
14 KiB
Vue
472 lines
14 KiB
Vue
|
|
<template>
|
||
|
|
<div class="min-h-screen relative overflow-hidden">
|
||
|
|
<!-- Background layers with 3D perspective and zoom effect -->
|
||
|
|
<div class="bg-perspective-container">
|
||
|
|
<!-- Video background for intro/login routes (smooth transition from splash) -->
|
||
|
|
<video
|
||
|
|
v-if="useVideoBackground"
|
||
|
|
ref="videoElement"
|
||
|
|
class="bg-layer"
|
||
|
|
autoplay
|
||
|
|
loop
|
||
|
|
muted
|
||
|
|
playsinline
|
||
|
|
preload="auto"
|
||
|
|
poster="/assets/img/bg-4.jpg"
|
||
|
|
style="width: 100%; height: 100%; object-fit: cover; object-position: center; position: absolute; inset: 0; transform: scale(1); transition: none;"
|
||
|
|
@pause.prevent="handleVideoPause"
|
||
|
|
@ended="handleVideoEnded"
|
||
|
|
>
|
||
|
|
<source src="/assets/video/video-intro.mp4?v=7" type="video/mp4">
|
||
|
|
</video>
|
||
|
|
|
||
|
|
<!-- Static image background for other routes (not using video) -->
|
||
|
|
<div
|
||
|
|
v-else
|
||
|
|
class="bg-layer bg-zoom"
|
||
|
|
:class="{ 'bg-zoom-in': isTransitioning }"
|
||
|
|
:style="{ backgroundImage: `url('/assets/img/${currentBackground}')` }"
|
||
|
|
:key="currentBackground"
|
||
|
|
></div>
|
||
|
|
|
||
|
|
|
||
|
|
<!-- Glitch overlay layer - only for non-video backgrounds -->
|
||
|
|
<div v-show="isGlitching && !useVideoBackground" class="bg-glitch-layer"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Content with 3D transitions -->
|
||
|
|
<div class="perspective-container-wrapper">
|
||
|
|
<div class="perspective-container">
|
||
|
|
<RouterView v-slot="{ Component, route }">
|
||
|
|
<Transition name="depth-forward">
|
||
|
|
<div :key="route.path" class="view-wrapper">
|
||
|
|
<component :is="Component" class="view-container" />
|
||
|
|
</div>
|
||
|
|
</Transition>
|
||
|
|
</RouterView>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { ref, watch, onMounted, computed } from 'vue'
|
||
|
|
import { useRoute } from 'vue-router'
|
||
|
|
|
||
|
|
const route = useRoute()
|
||
|
|
const currentBackground = ref('bg-4.jpg')
|
||
|
|
const isGlitching = ref(false)
|
||
|
|
const isTransitioning = ref(false)
|
||
|
|
const videoElement = ref<HTMLVideoElement | null>(null)
|
||
|
|
|
||
|
|
// Routes that should use video background (smooth transition from splash)
|
||
|
|
const videoBackgroundRoutes = ['/onboarding/intro', '/login']
|
||
|
|
|
||
|
|
// Check if current route should use video background
|
||
|
|
const useVideoBackground = computed(() => {
|
||
|
|
return videoBackgroundRoutes.includes(route.path)
|
||
|
|
})
|
||
|
|
|
||
|
|
// Map each route to a specific background image
|
||
|
|
// Note: bg-4.jpg is used for splash and /onboarding/intro for seamless transition
|
||
|
|
const routeBackgrounds: Record<string, string> = {
|
||
|
|
'/onboarding/intro': 'bg-4.jpg', // Video will be used instead
|
||
|
|
'/onboarding/options': 'bg-5.jpg',
|
||
|
|
'/onboarding/path': 'bg-3.jpg',
|
||
|
|
'/onboarding/did': 'bg-6.jpg',
|
||
|
|
'/onboarding/backup': 'bg-7.jpg',
|
||
|
|
'/onboarding/verify': 'bg-2.jpg',
|
||
|
|
'/onboarding/done': 'bg-1.jpg',
|
||
|
|
'/login': 'bg-1.jpg' // Video will be used instead
|
||
|
|
}
|
||
|
|
|
||
|
|
// Restore video time from splash screen for seamless transition
|
||
|
|
function restoreVideoTime() {
|
||
|
|
if (videoElement.value && useVideoBackground.value) {
|
||
|
|
const savedTime = sessionStorage.getItem('video_intro_currentTime')
|
||
|
|
const wasPlaying = sessionStorage.getItem('video_intro_wasPlaying') === 'true'
|
||
|
|
const savedPlaybackRate = sessionStorage.getItem('video_intro_playbackRate')
|
||
|
|
|
||
|
|
if (savedTime) {
|
||
|
|
const time = parseFloat(savedTime)
|
||
|
|
const playbackRate = savedPlaybackRate ? parseFloat(savedPlaybackRate) : 1.0
|
||
|
|
|
||
|
|
const setVideoTime = () => {
|
||
|
|
if (videoElement.value) {
|
||
|
|
// Set playback rate first for smooth playback
|
||
|
|
videoElement.value.playbackRate = playbackRate
|
||
|
|
// Set time with slight offset to ensure smooth transition (avoid frame boundary issues)
|
||
|
|
videoElement.value.currentTime = Math.max(0, time - 0.05)
|
||
|
|
|
||
|
|
// If video was playing, ensure it continues playing immediately
|
||
|
|
if (wasPlaying) {
|
||
|
|
// Use requestAnimationFrame for smoother transition
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
if (videoElement.value) {
|
||
|
|
videoElement.value.play().catch(err => {
|
||
|
|
console.warn('Video play failed after time restore:', err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clean up session storage after successful restore
|
||
|
|
sessionStorage.removeItem('video_intro_currentTime')
|
||
|
|
sessionStorage.removeItem('video_intro_wasPlaying')
|
||
|
|
sessionStorage.removeItem('video_intro_playbackRate')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (videoElement.value.readyState >= 2) {
|
||
|
|
// Video can play through current position, set time immediately
|
||
|
|
setVideoTime()
|
||
|
|
} else if (videoElement.value.readyState >= 1) {
|
||
|
|
// Video metadata loaded, set time immediately
|
||
|
|
setVideoTime()
|
||
|
|
} else {
|
||
|
|
// Wait for metadata to load
|
||
|
|
const handleLoadedMetadata = () => {
|
||
|
|
setVideoTime()
|
||
|
|
if (videoElement.value) {
|
||
|
|
videoElement.value.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
videoElement.value.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure video plays when route uses video background
|
||
|
|
watch([useVideoBackground, route], ([useVideo]) => {
|
||
|
|
if (useVideo && videoElement.value) {
|
||
|
|
// Use requestAnimationFrame for smoother transition
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
if (videoElement.value) {
|
||
|
|
// Restore video time for seamless transition first
|
||
|
|
restoreVideoTime()
|
||
|
|
// Then ensure it's playing - use double RAF for smoother transition
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
if (videoElement.value && videoElement.value.paused) {
|
||
|
|
videoElement.value.play().catch(err => {
|
||
|
|
console.warn('Video autoplay failed:', err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}, { immediate: true })
|
||
|
|
|
||
|
|
// Also ensure video plays on mount if route uses video
|
||
|
|
onMounted(() => {
|
||
|
|
if (useVideoBackground.value && videoElement.value) {
|
||
|
|
// Use requestAnimationFrame for smoother transition
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
if (videoElement.value) {
|
||
|
|
// Restore video time for seamless transition
|
||
|
|
restoreVideoTime()
|
||
|
|
// Use double RAF for smoother playback start
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
if (videoElement.value) {
|
||
|
|
videoElement.value.play().catch(err => {
|
||
|
|
console.warn('Video autoplay failed on mount:', err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Watch for video element to restore time when it becomes available
|
||
|
|
watch(videoElement, (element) => {
|
||
|
|
if (element && useVideoBackground.value) {
|
||
|
|
// Use requestAnimationFrame for smoother transition
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
if (element) {
|
||
|
|
// Try to restore immediately if metadata already loaded
|
||
|
|
if (element.readyState >= 2) {
|
||
|
|
restoreVideoTime()
|
||
|
|
} else if (element.readyState >= 1) {
|
||
|
|
restoreVideoTime()
|
||
|
|
} else {
|
||
|
|
// Wait for metadata to load
|
||
|
|
const handleLoadedMetadata = () => {
|
||
|
|
restoreVideoTime()
|
||
|
|
element.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||
|
|
}
|
||
|
|
element.addEventListener('loadedmetadata', handleLoadedMetadata, { once: true })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Watch route changes for background swaps, zoom, and glitch
|
||
|
|
watch(() => route.path, (newPath, oldPath) => {
|
||
|
|
const newBg = routeBackgrounds[newPath]
|
||
|
|
const oldUsesVideo = videoBackgroundRoutes.includes(oldPath || '')
|
||
|
|
const newUsesVideo = videoBackgroundRoutes.includes(newPath)
|
||
|
|
|
||
|
|
// If both old and new routes use video, don't restart video - keep it playing seamlessly
|
||
|
|
if (oldUsesVideo && newUsesVideo && videoElement.value) {
|
||
|
|
// Video continues seamlessly, just ensure it's playing
|
||
|
|
if (videoElement.value.paused) {
|
||
|
|
videoElement.value.play().catch(err => {
|
||
|
|
console.warn('Video play failed on route change:', err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
// No glitch effect, no zoom, no transitions for video-to-video
|
||
|
|
isGlitching.value = false
|
||
|
|
isTransitioning.value = false
|
||
|
|
return // Skip background change logic for video-to-video transitions
|
||
|
|
}
|
||
|
|
|
||
|
|
// If transitioning from video to non-video or vice versa, no glitch, no zoom (smooth transition)
|
||
|
|
if (oldUsesVideo || newUsesVideo) {
|
||
|
|
isGlitching.value = false
|
||
|
|
isTransitioning.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only update if we have a defined background for this route and it's different
|
||
|
|
if (newBg && newBg !== currentBackground.value) {
|
||
|
|
// Trigger zoom animation ONLY for non-video routes (never for video)
|
||
|
|
if (!newUsesVideo && !oldUsesVideo) {
|
||
|
|
isTransitioning.value = true
|
||
|
|
|
||
|
|
// Change background
|
||
|
|
currentBackground.value = newBg
|
||
|
|
|
||
|
|
// Only trigger glitch for non-video background changes
|
||
|
|
setTimeout(() => {
|
||
|
|
isGlitching.value = true
|
||
|
|
setTimeout(() => {
|
||
|
|
isGlitching.value = false
|
||
|
|
}, 500) // Match glitch duration
|
||
|
|
|
||
|
|
// Reset zoom after glitch
|
||
|
|
isTransitioning.value = false
|
||
|
|
}, 1500 + 50) // Wait for 3D transition (1500ms) + small delay - matches splash timing
|
||
|
|
} else {
|
||
|
|
// Smooth transition for video routes - no glitch, no zoom, no effects at all
|
||
|
|
currentBackground.value = newBg
|
||
|
|
isTransitioning.value = false
|
||
|
|
isGlitching.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Prevent video from pausing during transitions
|
||
|
|
function handleVideoPause(event: Event) {
|
||
|
|
if (useVideoBackground.value && videoElement.value) {
|
||
|
|
// Prevent pause and immediately resume playback
|
||
|
|
event.preventDefault()
|
||
|
|
videoElement.value.play().catch(err => {
|
||
|
|
console.warn('Video play failed after pause prevention:', err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle video ended - restart immediately for seamless loop
|
||
|
|
function handleVideoEnded() {
|
||
|
|
if (useVideoBackground.value && videoElement.value) {
|
||
|
|
videoElement.value.currentTime = 0
|
||
|
|
videoElement.value.play().catch(err => {
|
||
|
|
console.warn('Video play failed after loop:', err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update body class to disable global glitch effects ONLY for video backgrounds
|
||
|
|
// This class is ONLY added on /onboarding/intro and /login routes
|
||
|
|
// All other routes will have glitch effects enabled (normal behavior)
|
||
|
|
watch(useVideoBackground, (usesVideo) => {
|
||
|
|
if (usesVideo) {
|
||
|
|
// Add class ONLY on video background screens (/onboarding/intro, /login)
|
||
|
|
// This disables glitch effects ONLY on these screens
|
||
|
|
document.body.classList.add('video-background-active')
|
||
|
|
} else {
|
||
|
|
// Remove class on all other screens to re-enable glitch effects
|
||
|
|
document.body.classList.remove('video-background-active')
|
||
|
|
}
|
||
|
|
}, { immediate: true })
|
||
|
|
|
||
|
|
// Initialize background on mount based on current route
|
||
|
|
onMounted(() => {
|
||
|
|
const bg = routeBackgrounds[route.path]
|
||
|
|
if (bg) {
|
||
|
|
currentBackground.value = bg
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ensure no transitions or effects on mount for video backgrounds
|
||
|
|
if (useVideoBackground.value) {
|
||
|
|
isTransitioning.value = false
|
||
|
|
isGlitching.value = false
|
||
|
|
document.body.classList.add('video-background-active')
|
||
|
|
}
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
/* Wrapper to contain perspective without clipping */
|
||
|
|
.perspective-container-wrapper {
|
||
|
|
position: relative;
|
||
|
|
overflow: hidden;
|
||
|
|
min-height: 100vh;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Perspective container for 3D depth effect */
|
||
|
|
.perspective-container {
|
||
|
|
perspective: 1200px;
|
||
|
|
perspective-origin: 50% 50%;
|
||
|
|
position: relative;
|
||
|
|
min-height: 100vh;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* View wrapper - allows smooth transitions with absolute positioning */
|
||
|
|
.view-wrapper {
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
transform-style: preserve-3d;
|
||
|
|
backface-visibility: hidden;
|
||
|
|
will-change: transform, opacity;
|
||
|
|
}
|
||
|
|
|
||
|
|
.view-container {
|
||
|
|
height: 100%;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Forward transition: Current screen pulls forward, new screen emerges from back */
|
||
|
|
.depth-forward-enter-active.view-wrapper,
|
||
|
|
.depth-forward-leave-active.view-wrapper {
|
||
|
|
transition: all 0.7s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||
|
|
}
|
||
|
|
|
||
|
|
.depth-forward-enter-from.view-wrapper {
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateZ(-1500px) scale(0.5);
|
||
|
|
filter: blur(8px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.depth-forward-enter-to.view-wrapper {
|
||
|
|
opacity: 1;
|
||
|
|
transform: translateZ(0) scale(1);
|
||
|
|
filter: blur(0px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.depth-forward-leave-from.view-wrapper {
|
||
|
|
opacity: 1;
|
||
|
|
transform: translateZ(0) scale(1);
|
||
|
|
filter: blur(0px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.depth-forward-leave-to.view-wrapper {
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateZ(600px) scale(1.4);
|
||
|
|
filter: blur(12px);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Background zoom effect - makes you feel like you're going deeper */
|
||
|
|
.bg-zoom {
|
||
|
|
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||
|
|
transform: scale(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
.bg-zoom-in {
|
||
|
|
transform: scale(1.15);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Enhanced effect with rotation for more console-like feel */
|
||
|
|
@media (min-width: 768px) {
|
||
|
|
.depth-forward-enter-from.view-wrapper {
|
||
|
|
transform: translateZ(-1500px) scale(0.5) rotateX(15deg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.depth-forward-leave-to.view-wrapper {
|
||
|
|
transform: translateZ(600px) scale(1.4) rotateX(-10deg);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Background 3D container */
|
||
|
|
.bg-perspective-container {
|
||
|
|
position: fixed;
|
||
|
|
inset: 0;
|
||
|
|
perspective: 1000px;
|
||
|
|
perspective-origin: 50% 50%;
|
||
|
|
z-index: -10;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.bg-layer {
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
background-size: cover;
|
||
|
|
background-position: center;
|
||
|
|
background-repeat: no-repeat;
|
||
|
|
transform-style: preserve-3d;
|
||
|
|
backface-visibility: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Video background styling - video element itself has bg-layer class */
|
||
|
|
.bg-layer video,
|
||
|
|
video.bg-layer {
|
||
|
|
width: 100%;
|
||
|
|
height: 100%;
|
||
|
|
object-fit: cover;
|
||
|
|
object-position: center;
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.bg-static {
|
||
|
|
opacity: 1;
|
||
|
|
transform: translateZ(0) scale(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Glitch overlay layer */
|
||
|
|
.bg-glitch-layer {
|
||
|
|
position: absolute;
|
||
|
|
inset: 0;
|
||
|
|
pointer-events: none;
|
||
|
|
z-index: 10;
|
||
|
|
background: rgba(255, 255, 255, 0.1);
|
||
|
|
mix-blend-mode: overlay;
|
||
|
|
opacity: 0;
|
||
|
|
animation: bg-glitch-flash 500ms ease-in-out;
|
||
|
|
}
|
||
|
|
|
||
|
|
@keyframes bg-glitch-flash {
|
||
|
|
0%, 100% {
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateX(0);
|
||
|
|
}
|
||
|
|
10% {
|
||
|
|
opacity: 0.3;
|
||
|
|
transform: translateX(-3px);
|
||
|
|
}
|
||
|
|
20% {
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateX(3px);
|
||
|
|
}
|
||
|
|
30% {
|
||
|
|
opacity: 0.4;
|
||
|
|
transform: translateX(-2px);
|
||
|
|
}
|
||
|
|
40% {
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateX(2px);
|
||
|
|
}
|
||
|
|
50% {
|
||
|
|
opacity: 0.2;
|
||
|
|
transform: translateX(-1px);
|
||
|
|
}
|
||
|
|
60% {
|
||
|
|
opacity: 0;
|
||
|
|
transform: translateX(1px);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|
||
|
|
|