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:
Dorian 2026-02-17 19:42:59 +00:00
parent 1b05b5b8f1
commit c72b97e940
8 changed files with 91 additions and 36 deletions

View File

@ -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",

Binary file not shown.

View File

@ -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

View File

@ -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(() => {

View File

@ -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) */

View File

@ -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'),

View File

@ -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,

View File

@ -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>