Enhance audio and UI interactions for improved user experience
- Added a prebuild script in package.json to copy audio assets for smoother audio playback. - Updated App.vue to ensure the router is ready before displaying content, addressing issues with hard refreshes. - Introduced a "Tap to start" feature in SplashScreen.vue to comply with browser autoplay policies for audio. - Enhanced playLoopStart function in useLoginSounds.ts to utilize the Web Audio API for better audio control. - Removed unnecessary redirect in router index.ts for cleaner routing logic. - Improved Dashboard.vue and Login.vue styles for better visual hierarchy and user engagement during transitions.
This commit is contained in:
parent
1b05b5b8f1
commit
c72b97e940
@ -15,7 +15,8 @@
|
|||||||
"build:docker": "vite build",
|
"build:docker": "vite build",
|
||||||
"build:production": "NODE_ENV=production vue-tsc -b && vite build --mode production",
|
"build:production": "NODE_ENV=production vue-tsc -b && vite build --mode production",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"type-check": "vue-tsc --noEmit"
|
"type-check": "vue-tsc --noEmit",
|
||||||
|
"prebuild": "cp ../../loop-start.mp3 public/assets/audio/ 2>/dev/null || true"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dockerode": "^4.0.9",
|
"dockerode": "^4.0.9",
|
||||||
|
|||||||
BIN
neode-ui/public/assets/audio/loop-start.mp3
Normal file
BIN
neode-ui/public/assets/audio/loop-start.mp3
Normal file
Binary file not shown.
@ -96,7 +96,7 @@ const isReady = ref(false)
|
|||||||
* - User has already seen the intro
|
* - User has already seen the intro
|
||||||
* - User is on a direct route (refresh/bookmark)
|
* - User is on a direct route (refresh/bookmark)
|
||||||
*/
|
*/
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
const isDirectRoute = route.path !== '/'
|
const isDirectRoute = route.path !== '/'
|
||||||
@ -104,8 +104,8 @@ onMounted(() => {
|
|||||||
if (seenIntro || isDirectRoute) {
|
if (seenIntro || isDirectRoute) {
|
||||||
showSplash.value = false
|
showSplash.value = false
|
||||||
document.body.classList.add('splash-complete')
|
document.body.classList.add('splash-complete')
|
||||||
// Set isReady immediately for direct routes or when intro is already seen
|
// Wait for router to finish initial navigation before showing content (fixes hard refresh)
|
||||||
// Router will handle navigation
|
await router.isReady()
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
}
|
}
|
||||||
// If splash should show, wait for it to complete
|
// If splash should show, wait for it to complete
|
||||||
|
|||||||
@ -96,9 +96,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Tap to start - required for audio (browser autoplay policy) -->
|
||||||
|
<div
|
||||||
|
v-if="showTapToStart"
|
||||||
|
class="absolute inset-0 z-[100] flex items-center justify-center bg-black/40 cursor-pointer"
|
||||||
|
@click="handleTapToStart"
|
||||||
|
>
|
||||||
|
<p class="font-mono text-white/90 text-lg sm:text-xl px-6 py-4 rounded-lg border border-white/20 bg-black/30 backdrop-blur-sm">
|
||||||
|
Tap to start
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Skip Button -->
|
<!-- Skip Button -->
|
||||||
<button
|
<button
|
||||||
v-if="!alienIntroComplete"
|
v-if="!alienIntroComplete && !showTapToStart"
|
||||||
@click="handleSkipClick"
|
@click="handleSkipClick"
|
||||||
class="absolute bottom-8 right-8 z-20 bg-black/60 border border-white/30 text-white/70 font-mono text-xs px-4 py-2 rounded backdrop-blur-[10px] hover:bg-black/80 hover:text-white/90 hover:border-white/50 hover:-translate-y-0.5 active:translate-y-0 transition-all duration-300"
|
class="absolute bottom-8 right-8 z-20 bg-black/60 border border-white/30 text-white/70 font-mono text-xs px-4 py-2 rounded backdrop-blur-[10px] hover:bg-black/80 hover:text-white/90 hover:border-white/50 hover:-translate-y-0.5 active:translate-y-0 transition-all duration-300"
|
||||||
>
|
>
|
||||||
@ -126,6 +137,7 @@ const MS_PER_CHAR = 55
|
|||||||
const BLINK_AFTER_TYPING = 1500
|
const BLINK_AFTER_TYPING = 1500
|
||||||
|
|
||||||
const showSplash = ref(true)
|
const showSplash = ref(true)
|
||||||
|
const showTapToStart = ref(true)
|
||||||
const backgroundOpacity = ref(0)
|
const backgroundOpacity = ref(0)
|
||||||
const alienIntroComplete = ref(false)
|
const alienIntroComplete = ref(false)
|
||||||
const fadeAlienIntro = ref(false)
|
const fadeAlienIntro = ref(false)
|
||||||
@ -213,6 +225,13 @@ watch([showWelcome, showLogo], ([welcome, logo]) => {
|
|||||||
// Check if user has seen intro
|
// Check if user has seen intro
|
||||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
|
|
||||||
|
function handleTapToStart() {
|
||||||
|
if (!showTapToStart.value) return
|
||||||
|
resumeAudioContext()
|
||||||
|
showTapToStart.value = false
|
||||||
|
startAlienIntro()
|
||||||
|
}
|
||||||
|
|
||||||
function handleSkipClick() {
|
function handleSkipClick() {
|
||||||
resumeAudioContext()
|
resumeAudioContext()
|
||||||
skipIntro()
|
skipIntro()
|
||||||
@ -383,17 +402,8 @@ onMounted(() => {
|
|||||||
showSplash.value = false
|
showSplash.value = false
|
||||||
document.body.classList.add('splash-complete')
|
document.body.classList.add('splash-complete')
|
||||||
emit('complete')
|
emit('complete')
|
||||||
} else {
|
|
||||||
startAlienIntro()
|
|
||||||
// Unlock audio on first user interaction (required for autoplay in most browsers)
|
|
||||||
const unlock = () => {
|
|
||||||
resumeAudioContext()
|
|
||||||
document.removeEventListener('click', unlock)
|
|
||||||
document.removeEventListener('touchstart', unlock)
|
|
||||||
}
|
|
||||||
document.addEventListener('click', unlock, { once: true })
|
|
||||||
document.addEventListener('touchstart', unlock, { once: true })
|
|
||||||
}
|
}
|
||||||
|
// Typing starts only after user taps "Tap to start" (required for loop-start + music)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@ -41,15 +41,29 @@ function playTone(
|
|||||||
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
|
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
|
||||||
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
||||||
|
|
||||||
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in */
|
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in.
|
||||||
|
* Uses Web Audio API so it plays after context is resumed (user gesture). */
|
||||||
export function playLoopStart() {
|
export function playLoopStart() {
|
||||||
|
const ctx = getContext()
|
||||||
|
if (!ctx) return
|
||||||
try {
|
try {
|
||||||
const audio = new Audio(LOOP_START_URL)
|
if (ctx.state === 'suspended') ctx.resume()
|
||||||
audio.volume = 0.5
|
|
||||||
audio.play().catch(() => {})
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
return
|
||||||
}
|
}
|
||||||
|
fetch(LOOP_START_URL)
|
||||||
|
.then((res) => res.arrayBuffer())
|
||||||
|
.then((buf) => ctx.decodeAudioData(buf))
|
||||||
|
.then((decoded) => {
|
||||||
|
const src = ctx.createBufferSource()
|
||||||
|
src.buffer = decoded
|
||||||
|
const gain = ctx.createGain()
|
||||||
|
gain.gain.value = 0.5
|
||||||
|
src.connect(gain)
|
||||||
|
gain.connect(ctx.destination)
|
||||||
|
src.start(0)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resume audio context (call on first user interaction to unlock autoplay) */
|
/** Resume audio context (call on first user interaction to unlock autoplay) */
|
||||||
|
|||||||
@ -56,7 +56,6 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/dashboard/', redirect: '/dashboard' },
|
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
component: () => import('../views/Dashboard.vue'),
|
component: () => import('../views/Dashboard.vue'),
|
||||||
|
|||||||
@ -69,7 +69,7 @@
|
|||||||
<!-- Sidebar - Desktop Only, animates in at end with separate parts -->
|
<!-- Sidebar - Desktop Only, animates in at end with separate parts -->
|
||||||
<aside
|
<aside
|
||||||
data-controller-zone="sidebar"
|
data-controller-zone="sidebar"
|
||||||
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col"
|
class="hidden md:flex w-[256px] flex-shrink-0 relative flex-col z-10"
|
||||||
:class="{ 'sidebar-animate': showZoomIn }"
|
:class="{ 'sidebar-animate': showZoomIn }"
|
||||||
>
|
>
|
||||||
<div class="sidebar-shell">
|
<div class="sidebar-shell">
|
||||||
@ -127,7 +127,7 @@
|
|||||||
<!-- Main Content (Xbox: Right goes here from sidebar) -->
|
<!-- Main Content (Xbox: Right goes here from sidebar) -->
|
||||||
<main
|
<main
|
||||||
data-controller-zone="main"
|
data-controller-zone="main"
|
||||||
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece"
|
class="flex-1 overflow-hidden relative pb-20 md:pb-0 glass-piece z-10"
|
||||||
:class="{ 'glass-throw-main': showZoomIn }"
|
:class="{ 'glass-throw-main': showZoomIn }"
|
||||||
>
|
>
|
||||||
<!-- Connection Status Banner -->
|
<!-- Connection Status Banner -->
|
||||||
@ -1011,9 +1011,11 @@ function getTransitionName(currentRoute: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* When not animating, show everything */
|
/* When not animating, show everything (direct load / hard refresh) */
|
||||||
aside:not(.sidebar-animate) .sidebar-shell {
|
aside:not(.sidebar-animate) .sidebar-shell {
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
aside:not(.sidebar-animate) .sidebar-inner,
|
aside:not(.sidebar-animate) .sidebar-inner,
|
||||||
aside:not(.sidebar-animate) .sidebar-logo,
|
aside:not(.sidebar-animate) .sidebar-logo,
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen flex items-center justify-center p-4 relative z-10">
|
<div class="min-h-screen flex items-center justify-center p-4 relative z-10 login-fly-perspective">
|
||||||
<div class="w-full max-w-md relative z-20">
|
<div class="w-full max-w-md relative z-20">
|
||||||
<!-- Login Card -->
|
<!-- Login Card - flies towards user on success -->
|
||||||
<div
|
<div
|
||||||
class="glass-card p-8 pt-20 relative login-card overflow-visible transition-all duration-300"
|
class="glass-card p-8 pt-20 relative login-card overflow-visible"
|
||||||
:class="{ 'whoosh-away': whooshAway }"
|
:class="{ 'login-fly-towards': whooshAway }"
|
||||||
>
|
>
|
||||||
<!-- Logo - half in, half out of container -->
|
<!-- Logo - half in, half out of container -->
|
||||||
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
|
<div class="absolute -top-10 left-1/2 -translate-x-1/2 z-10">
|
||||||
@ -202,7 +202,7 @@ async function handleSetup() {
|
|||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
loginTransition.setJustLoggedIn(true)
|
loginTransition.setJustLoggedIn(true)
|
||||||
await store.login(password.value)
|
await store.login(password.value)
|
||||||
await new Promise(r => setTimeout(r, 350))
|
await new Promise(r => setTimeout(r, 520))
|
||||||
router.replace({ name: 'home' })
|
router.replace({ name: 'home' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
whooshAway.value = false
|
whooshAway.value = false
|
||||||
@ -225,7 +225,7 @@ async function handleLogin() {
|
|||||||
whooshAway.value = true
|
whooshAway.value = true
|
||||||
playLoginSuccessWhoosh()
|
playLoginSuccessWhoosh()
|
||||||
loginTransition.setJustLoggedIn(true)
|
loginTransition.setJustLoggedIn(true)
|
||||||
await new Promise(r => setTimeout(r, 350))
|
await new Promise(r => setTimeout(r, 520))
|
||||||
router.replace({ name: 'home' })
|
router.replace({ name: 'home' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
whooshAway.value = false
|
whooshAway.value = false
|
||||||
@ -245,10 +245,39 @@ function replayIntro() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Whoosh accent - login card blurs and shrinks as whoosh plays */
|
/* Perspective for 3D fly effect */
|
||||||
.whoosh-away {
|
.login-fly-perspective {
|
||||||
transform: scale(0.92);
|
perspective: 1200px;
|
||||||
filter: blur(6px);
|
perspective-origin: center center;
|
||||||
opacity: 0.6;
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||||
|
opacity 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||||
|
filter 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fly towards user - card zooms forward as it transitions out */
|
||||||
|
.login-fly-towards {
|
||||||
|
animation: login-fly-towards 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes login-fly-towards {
|
||||||
|
0% {
|
||||||
|
transform: translateZ(0) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateZ(180px) scale(1.4);
|
||||||
|
opacity: 0.95;
|
||||||
|
filter: blur(2px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateZ(400px) scale(2);
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(8px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user