Initial commit - Navi.FM v3

This commit is contained in:
Collin Kasbergen
2026-07-22 13:23:22 +02:00
commit badafc91e3
55 changed files with 6384 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1130
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.185.1",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+241
View File
@@ -0,0 +1,241 @@
<script setup>
import { ref } from 'vue'
import AudioPlayer from './components/AudioPlayer.vue'
import AdminPanel from './components/AdminPanel.vue'
import SetupWizard from './components/SetupWizard.vue'
import { settingsState, fetchSettings, THEME_PALETTE } from './store/settings'
import { onMounted, computed, watch } from 'vue'
const currentView = ref('loading') // 'loading', 'setup', 'player', 'admin'
const authToken = ref(localStorage.getItem('adminToken') || null)
const checkSetupStatus = async () => {
try {
const res = await fetch('/api/setup/status')
if (res.ok) {
const data = await res.json()
if (!data.is_setup) {
currentView.value = 'setup'
} else {
currentView.value = 'player'
fetchSettings()
}
} else {
currentView.value = 'player'
fetchSettings()
}
} catch (err) {
console.error('Failed to check setup status', err)
currentView.value = 'player'
fetchSettings()
}
}
onMounted(() => {
checkSetupStatus()
})
const themeStyles = computed(() => {
const palette = THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple
return {
'--theme-glow': palette.glow,
'--theme-accent': palette.accent,
'--theme-dark': palette.dark
}
})
const toggleAdmin = () => {
currentView.value = currentView.value === 'player' ? 'admin' : 'player'
}
watch(() => settingsState.appTitle, (newTitle) => {
if (newTitle) {
document.title = newTitle
}
}, { immediate: true })
watch(() => [settingsState.themeColor, settingsState.faviconSvgOverlay, settingsState.faviconSvgColor], () => {
const cacheBust = Date.now()
let link = document.querySelector("link[rel~='icon']")
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.type = 'image/svg+xml'
link.href = `/favicon.svg?v=${cacheBust}`
}, { immediate: true })
const handleLogin = (token) => {
if (token) {
authToken.value = token
localStorage.setItem('adminToken', token)
} else {
authToken.value = null
localStorage.removeItem('adminToken')
}
}
</script>
<template>
<main class="app-container" :style="themeStyles">
<!-- Admin Toggle Button -->
<button v-if="currentView === 'player'" @click="toggleAdmin" class="admin-toggle-btn" aria-label="Open Admin Panel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.73 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.49-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
</button>
<transition name="fade">
<div v-show="currentView === 'player'" class="view-layer">
<AudioPlayer />
</div>
</transition>
<transition name="fade">
<div v-show="currentView === 'admin'" class="view-layer">
<AdminPanel
v-if="currentView === 'admin' && currentView !== 'setup'"
:auth-token="authToken"
@login="handleLogin"
@close="toggleAdmin"
class="admin-flyout"
/>
</div>
</transition>
<div v-if="currentView === 'loading'" class="loading-screen">
<div class="spinner"></div>
</div>
<SetupWizard v-else-if="currentView === 'setup'" @complete="currentView = 'player'; fetchSettings()" />
</main>
</template>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap');
:root {
--glass-bg: rgba(15, 7, 30, 0.45);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-border-hover: rgba(139, 92, 246, 0.3);
--glass-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1);
--theme-glow: #8b5cf6;
--theme-accent: #c4b5fd;
--theme-dark: #4c1d95;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
--transition-bounce: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: #06030c;
color: var(--text-primary);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
#app {
width: 100%;
height: 100vh;
}
.app-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.admin-toggle-btn {
position: absolute;
bottom: 20px;
right: 20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-muted);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
z-index: 50;
transition: var(--transition-smooth);
}
.admin-toggle-btn svg {
width: 20px;
height: 20px;
}
.admin-toggle-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
transform: rotate(90deg);
}
.view-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 2rem 0;
overflow-y: auto;
overflow-x: hidden;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.loading-screen {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background: radial-gradient(circle at center, #1e1b4b, #000);
z-index: 9999;
}
.loading-screen .spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(139, 92, 246, 0.2);
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

File diff suppressed because it is too large Load Diff
+972
View File
@@ -0,0 +1,972 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import ThreeOrb from './ThreeOrb.vue'
import { playerState, togglePlay, toggleMute, setVolume, startPolling, stopPolling } from '../store/player'
import { settingsState } from '../store/settings'
const showInfoModal = ref(false)
const showHistory = ref(false)
const formatTimeAgo = (timestamp) => {
if (!timestamp) return 'Never'
const serverNow = Math.floor(Date.now() / 1000) - settingsState.serverTimeOffset
let seconds = serverNow - timestamp
if (seconds < 0) seconds = 0 // Prevent negative times if slight jitter occurs
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
return `${Math.floor(minutes / 60)}h ago`
}
onMounted(() => {
startPolling()
})
onBeforeUnmount(() => {
stopPolling()
})
</script>
<template>
<div class="audio-player-view">
<!-- Background glowing ambient blobs -->
<div class="glow-bg">
<div class="blob blob-purple"></div>
<div class="blob blob-indigo"></div>
</div>
<div class="player-container">
<!-- Main glassmorphic player card -->
<div class="player-card" :class="{ 'card-playing': playerState.isPlaying }">
<!-- Dynamic Island Header -->
<div class="dynamic-island">
<button @click="showHistory = !showHistory" class="island-btn" title="Recently Played" aria-label="Recently Played">
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
<div class="island-divider"></div>
<div class="island-center" :class="{ 'live-active': playerState.isStreamOnline }">
<span class="live-dot" :class="{ 'dot-pulse': playerState.isStreamOnline }"></span>
<span class="island-text">{{ playerState.isStreamOnline ? 'LIVE' : 'OFFLINE' }}</span>
</div>
<div class="island-divider"></div>
<button @click="showInfoModal = true" class="island-btn" aria-label="About Navi.FM">?</button>
</div>
<!-- History Pull-out -->
<div class="history-pullout" :class="{ 'history-open': showHistory }">
<div class="history-pullout-header">
<h4>Recently Played</h4>
<button @click="showHistory = false" class="close-history">×</button>
</div>
<div class="history-list" v-if="settingsState.history && settingsState.history.length > 0">
<div v-for="t in settingsState.history" :key="t.id" class="history-item">
<div class="history-info">
<span class="history-title">{{ t.title }}</span>
<span class="history-artist">{{ t.artist }}</span>
</div>
<span class="history-time">{{ formatTimeAgo(t.last_played) }}</span>
</div>
</div>
<div v-else class="empty-history">
No history available yet.
</div>
</div>
<!-- Station Logo / 3D Orb -->
<div class="center-content-wrapper">
<div class="logo-section">
<div class="logo-ring" :class="{ 'ring-pulse': playerState.isPlaying }">
<ThreeOrb
:isPlaying="playerState.isPlaying"
:isLoading="playerState.isLoading"
:bpm="playerState.bpm"
:intensity="playerState.intensity"
:brightness="playerState.brightness"
/>
</div>
</div>
</div>
<!-- Station Info -->
<div class="station-info">
<h2 class="station-title">{{ settingsState.appTitle }}</h2>
<p class="station-tagline">{{ settingsState.appTagline }}</p>
</div>
<!-- Audio Visualizer Waves -->
<div class="visualizer">
<div
v-for="n in 9"
:key="n"
class="vis-bar"
:class="{ 'vis-active': playerState.isPlaying, 'vis-buffering': playerState.isLoading }"
:style="{ animationDelay: `${n * 0.12}s` }"
></div>
</div>
<!-- Display Messages (Loading / Errors) -->
<div class="status-box">
<transition name="fade" mode="out-in">
<p v-if="playerState.error" class="status-text error-text">{{ playerState.error }}</p>
<p v-else-if="playerState.isLoading" class="status-text loading-text">
Connecting to stream<span class="loading-dots">...</span>
</p>
<p v-else-if="playerState.isPlaying" class="status-text active-text">
<span v-if="playerState.currentTrack">{{ playerState.currentTrack.artist }} - {{ playerState.currentTrack.title }}</span>
<span v-else>Streaming Live Audio</span>
</p>
<p v-else class="status-text idle-text">Ready to Tune In</p>
</transition>
</div>
<!-- Central Controller -->
<div class="control-center">
<button
@click="togglePlay"
class="play-button"
:class="{ 'play-btn-active': playerState.isPlaying, 'play-btn-loading': playerState.isLoading }"
:aria-label="playerState.isPlaying ? 'Pause stream' : 'Play stream'"
:disabled="playerState.isLoading"
>
<!-- Play Icon -->
<svg v-if="!playerState.isPlaying && !playerState.isLoading" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="play-icon">
<path d="M8 5.14v14c0 .86.94 1.39 1.66.9l10-7c.61-.43.61-1.37 0-1.8l-10-7A1.07 1.07 0 0 0 8 5.14z"/>
</svg>
<!-- Pause Icon -->
<svg v-else-if="playerState.isPlaying && !playerState.isLoading" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="pause-icon">
<path d="M6 19h4c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1H6c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1zm8-13v12c0 .55.45 1 1 1h4c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1h-4c-.55 0-1 .45-1 1z"/>
</svg>
<!-- Spinner Ring for Loading State -->
<div v-else class="loading-spinner"></div>
</button>
</div>
<!-- Volume Controller Section -->
<div class="volume-container">
<button @click="toggleMute" class="mute-button" :aria-label="playerState.isMuted ? 'Unmute' : 'Mute'">
<!-- Muted / Volume Off Icon -->
<svg v-if="playerState.isMuted || playerState.volume === 0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="vol-icon">
<path d="M3.63 3.63a.996.996 0 0 0 0 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-3.79l4.3 4.3a6.83 6.83 0 0 0 1.95-1.28c.38-.38.37-1 .01-1.37a.996.996 0 0 0-1.41 0c-.35.35-.74.65-1.16.89l-2.09-2.09c.65-.63 1.15-1.4 1.48-2.27c.18-.49-.12-1.02-.64-1.02c-.39 0-.74.24-.87.6c-.22.6-.57 1.13-1.02 1.58l-2.31-2.3V4c0-.89-1.08-1.34-1.71-.71L9.7 6.59l-4.66-4.66a.996.996 0 0 0-1.41 0zM12 4v7.29l-2.37-2.37L12 6.59V4zm6.5 8c0-2.33-1.02-4.42-2.65-5.85c-.39-.34-.99-.3-.1.32a.996.996 0 0 0 0 1.41c1.13.99 1.85 2.43 1.85 4.12c0 1.15-.36 2.22-.98 3.1l1.43 1.43c.85-1.37 1.35-2.98 1.35-4.81z"/>
</svg>
<!-- Volume Low Icon -->
<svg v-else-if="playerState.volume < 0.4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="vol-icon">
<path d="M14 8.83v6.34c1.17-.83 2-2.2 2-3.75s-.83-2.92-2-3.75zM7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V4.71c0-.89-1.08-1.34-1.71-.71L7 9z"/>
</svg>
<!-- Volume High Icon -->
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="vol-icon">
<path d="M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V4.71c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.61.85C17.06 6.54 19 9.04 19 12s-1.94 5.46-4.39 6.5a.994.994 0 0 0-.61.85v.2c0 .67.73 1.07 1.3.73C18.66 18.77 21 15.66 21 12s-2.34-6.77-5.7-8.28c-.57-.26-1.3.15-1.3.73z"/>
</svg>
</button>
<div class="slider-wrapper">
<input
type="range"
min="0"
max="1"
step="0.01"
:value="playerState.volume"
@input="setVolume(Number($event.target.value))"
:disabled="playerState.isMuted"
class="volume-slider"
:style="{ '--val': `${playerState.isMuted ? 0 : playerState.volume * 100}%` }"
/>
</div>
</div>
</div>
</div>
<!-- Glassmorphic Modal Overlay -->
<transition name="fade">
<div v-if="showInfoModal" class="modal-overlay" @click.self="showInfoModal = false">
<div class="modal-card">
<button @click="showInfoModal = false" class="close-modal-button" aria-label="Close modal">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="close-icon">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
<div v-if="settingsState.infoModalText" class="modal-content">
<p style="white-space: pre-wrap; line-height: 1.6;">{{ settingsState.infoModalText }}</p>
</div>
<div class="modal-footer" :class="{ 'no-content': !settingsState.infoModalText }">
Powered by Navi.FM
</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
/* Main Player Layout */
.audio-player-view {
width: 100%;
margin: auto;
display: flex;
justify-content: center;
}
.player-container {
position: relative;
z-index: 10;
perspective: 1000px;
margin: auto;
}
.player-card {
width: 360px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 28px;
padding: 30px 24px 70px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: var(--glass-shadow);
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.card-playing {
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.8), 0 0 40px rgba(139, 92, 246, 0.15);
}
/* Status System */
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
transition: var(--transition-smooth);
}
.live-active .live-dot {
background: #f43f5e;
box-shadow: 0 0 10px #f43f5e;
}
.dot-pulse {
animation: pulse-red 2s infinite ease-in-out;
}
.live-text {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-secondary);
}
.live-active .live-text {
color: var(--text-primary);
}
.center-content-wrapper {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 0;
margin-bottom: 1rem;
width: 100%;
}
.logo-section {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.logo-ring {
width: 180px;
height: 180px;
border-radius: 50%;
background: radial-gradient(circle, rgba(139, 92, 246, 0.03) 0%, rgba(6, 3, 12, 0.3) 100%);
border: 1px dashed rgba(139, 92, 246, 0.15);
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
transition: var(--transition-smooth);
}
.ring-pulse {
border-color: rgba(139, 92, 246, 0.4);
box-shadow: 0 0 25px rgba(139, 92, 246, 0.12);
animation: ring-glow 3s infinite ease-in-out, spin-ring 25s linear infinite;
}
/* Typography and Station details */
.station-info {
text-align: center;
margin-bottom: 22px;
}
.station-title {
font-size: 28px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--text-primary);
background: linear-gradient(135deg, var(--text-primary) 30%, var(--theme-accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px rgba(139, 92, 246, 0.2);
}
.station-tagline {
font-size: 12px;
font-weight: 400;
color: var(--text-secondary);
margin-top: 6px;
letter-spacing: 0.02em;
}
/* Audio Visualizer */
.visualizer {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 40px;
margin-bottom: 12px;
}
.vis-bar {
width: 3px;
height: 6px;
background: linear-gradient(to top, var(--theme-dark), var(--theme-accent));
border-radius: 3px;
transition: var(--transition-smooth);
opacity: 0.35;
}
.vis-active {
animation: bounce-bar 1.2s infinite ease-in-out;
opacity: 1;
}
.vis-buffering {
animation: pulse-bar 1s infinite ease-in-out;
opacity: 0.6;
}
/* Play/Pause Button */
.control-center {
margin-bottom: 26px;
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.play-button {
width: 76px;
height: 76px;
border-radius: 50%;
background: linear-gradient(135deg, var(--theme-glow) 0%, var(--theme-dark) 100%);
border: none;
color: var(--text-primary);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.4), inset 0 2px 4px rgba(255, 255, 255, 0.2);
transition: var(--transition-bounce);
position: relative;
}
.play-button:hover:not(:disabled) {
transform: scale(1.08);
box-shadow: 0 12px 30px rgba(139, 92, 246, 0.6), inset 0 2px 4px rgba(255, 255, 255, 0.3);
}
.play-button:active:not(:disabled) {
transform: scale(0.96);
}
.play-button:disabled {
opacity: 0.85;
cursor: not-allowed;
}
.play-icon, .pause-icon {
width: 32px;
height: 32px;
transition: var(--transition-smooth);
}
.skip-button {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: var(--transition-bounce);
}
.skip-button:hover {
background: rgba(139, 92, 246, 0.2);
color: var(--text-primary);
transform: scale(1.1);
border-color: rgba(139, 92, 246, 0.5);
}
.skip-icon {
width: 24px;
height: 24px;
}
/* Spinner for loading state */
.loading-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top: 3px solid var(--text-primary);
border-radius: 50%;
animation: spin 1s infinite linear;
}
/* Volume Slider UI */
.volume-container {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 0 10px;
}
.mute-button {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transition: var(--transition-smooth);
width: 36px;
height: 36px;
border-radius: 50%;
}
.mute-button:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.vol-icon {
width: 20px;
height: 20px;
}
.slider-wrapper {
flex-grow: 1;
display: flex;
align-items: center;
}
.volume-slider {
-webkit-appearance: none;
width: 100%;
height: 5px;
border-radius: 3px;
background: linear-gradient(to right, var(--theme-glow) 0%, var(--theme-glow) var(--val), rgba(255, 255, 255, 0.1) var(--val), rgba(255, 255, 255, 0.1) 100%);
outline: none;
cursor: pointer;
transition: background 0.1s ease;
}
.volume-slider:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Custom thumb styling */
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--text-primary);
border: 2px solid var(--theme-glow);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.5);
transition: var(--transition-bounce);
}
.volume-slider:hover::-webkit-slider-thumb:not(:disabled) {
transform: scale(1.25);
box-shadow: 0 0 14px rgba(139, 92, 246, 0.8);
}
/* Status Box */
.status-box {
height: 24px;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 12px;
}
.status-text {
font-size: 12px;
letter-spacing: 0.04em;
font-weight: 500;
}
.idle-text { color: var(--text-muted); }
.active-text { color: var(--theme-accent); font-family: 'JetBrains Mono', monospace; font-size: 11px;}
.loading-text { color: var(--text-secondary); }
.error-text { color: #f43f5e; font-weight: 600; }
.loading-dots {
display: inline-block;
animation: loading-dots 1.5s infinite;
width: 15px;
text-align: left;
}
/* Animations & Transitions */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes pulse-red {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.9); }
}
@keyframes ring-glow {
0%, 100% {
border-color: rgba(139, 92, 246, 0.1);
box-shadow: 0 0 15px rgba(139, 92, 246, 0.05);
}
50% {
border-color: rgba(139, 92, 246, 0.45);
box-shadow: 0 0 30px rgba(139, 92, 246, 0.25);
}
}
@keyframes spin-ring {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes bounce-bar {
0%, 100% { height: 6px; }
50% { height: 32px; }
}
@keyframes pulse-bar {
0%, 100% { height: 6px; opacity: 0.3; }
50% { height: 16px; opacity: 0.8; }
}
@keyframes loading-dots {
0% { content: ''; }
33% { content: '.'; }
66% { content: '..'; }
100% { content: '...'; }
}
.dynamic-island {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(15, 7, 30, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 0.35rem 0.5rem;
border-radius: 40px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 20;
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
transition: var(--transition-smooth);
}
.dynamic-island:hover {
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 15px 35px rgba(0,0,0,0.4);
}
.island-btn {
background: rgba(255, 255, 255, 0.05);
border: none;
color: white;
width: 1.8rem;
height: 1.8rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
font-weight: 600;
font-size: 0.9rem;
}
.island-btn:hover {
background: rgba(255, 255, 255, 0.15);
transform: scale(1.05);
}
.island-divider {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.1);
margin: 0 0.2rem;
}
.island-divider-small {
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
margin: 0 0.2rem;
}
.island-center {
display: flex;
align-items: center;
gap: 6px;
padding: 0 0.4rem;
}
.island-text {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.live-active .island-text {
color: white;
}
.island-icon {
font-size: 0.75rem;
opacity: 0.8;
}
.history-pullout {
position: absolute;
top: 4.5rem;
right: 1.5rem;
width: 260px;
background: rgba(10, 10, 10, 0.85);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
z-index: 20;
transform: translateY(-10px);
opacity: 0;
pointer-events: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.history-pullout.history-open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.history-pullout-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.history-pullout-header h4 {
margin: 0;
font-size: 0.85rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
.close-history {
background: none;
border: none;
color: white;
font-size: 1.2rem;
cursor: pointer;
opacity: 0.7;
}
.close-history:hover {
opacity: 1;
}
.history-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 300px;
overflow-y: auto;
padding-right: 0.75rem;
}
.history-list::-webkit-scrollbar { width: 4px; }
.history-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.history-info {
display: flex;
flex-direction: column;
overflow: hidden;
}
.history-title {
color: white;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-artist {
color: var(--text-secondary);
font-size: 0.75rem;
}
.history-time {
color: var(--text-secondary);
font-size: 0.7rem;
white-space: nowrap;
margin-left: 0.5rem;
}
.empty-history {
color: var(--text-secondary);
font-size: 0.85rem;
text-align: center;
padding: 1rem 0;
}
/* Ambient glow blobs */
.glow-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 1;
pointer-events: none;
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.15;
mix-blend-mode: screen;
transition: var(--transition-smooth);
}
.blob-purple {
width: 450px;
height: 450px;
background-color: var(--theme-glow);
top: 15%;
left: 10%;
animation: float-blob-1 12s infinite alternate ease-in-out;
}
.blob-indigo {
width: 500px;
height: 500px;
background-color: #3b0764;
bottom: 10%;
right: 10%;
animation: float-blob-2 15s infinite alternate ease-in-out;
}
@keyframes float-blob-1 {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(50px, 40px) scale(1.15); }
}
@keyframes float-blob-2 {
0% { transform: translate(0, 0) scale(1.1); }
100% { transform: translate(-60px, -30px) scale(0.9); }
}
/* Page transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Info Button */
.info-trigger-button {
position: absolute;
top: 20px;
right: 20px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-secondary);
font-family: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
transition: var(--transition-smooth);
z-index: 5;
}
.info-trigger-button:hover {
color: var(--text-primary);
border-color: var(--theme-glow);
background: rgba(139, 92, 246, 0.15);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.2);
}
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(6, 3, 12, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
/* Modal Card */
.modal-card {
width: 100%;
max-width: 480px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 32px 28px;
position: relative;
box-shadow: var(--glass-shadow);
animation: modal-slide 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.close-modal-button {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
padding: 4px;
border-radius: 50%;
transition: var(--transition-smooth);
}
.close-modal-button:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.close-icon {
width: 18px;
height: 18px;
}
/* Modal Typography */
.modal-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.modal-section h3 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--theme-accent);
margin-bottom: 6px;
text-transform: uppercase;
}
.modal-content p {
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.6;
}
.modal-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.modal-footer.no-content {
margin-top: 0.5rem;
padding-top: 0;
border-top: none;
}
@keyframes modal-slide {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button type="button" class="counter" @click="count++">
Count is {{ count }}
</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>
+347
View File
@@ -0,0 +1,347 @@
<template>
<div class="setup-container">
<div class="setup-card glass-panel">
<div class="logo-area">
<h1>NAVI.FM</h1>
<p class="subtitle" v-if="step === 1">Welcome! Let's get things set up.</p>
<p class="subtitle" v-if="step === 2">Connect your library.</p>
<p class="subtitle" v-if="step === 3">Initializing Engine...</p>
</div>
<!-- Step 1: Admin Setup -->
<form v-if="step === 1" @submit.prevent="submitAdmin" class="setup-form">
<p class="step-desc">First, create an admin account to manage your station.</p>
<div class="form-group">
<label>Admin Username</label>
<input v-model="adminForm.username" type="text" required placeholder="admin" autofocus />
</div>
<div class="form-group">
<label>Admin Password</label>
<input v-model="adminForm.password" type="password" required placeholder="••••••••" />
</div>
<div class="form-group">
<label>Confirm Password</label>
<input v-model="adminForm.confirmPassword" type="password" required placeholder="••••••••" />
</div>
<div v-if="error" class="error-msg">{{ error }}</div>
<button type="submit" class="submit-btn" :disabled="isLoading">
{{ isLoading ? 'Saving...' : 'Next Step →' }}
</button>
</form>
<!-- Step 2: Navidrome Setup -->
<form v-if="step === 2" @submit.prevent="submitNavidrome" class="setup-form">
<p class="step-desc">Connect Navi.FM to your Navidrome server to start importing tracks.</p>
<div class="form-group">
<label>Navidrome URL</label>
<input v-model="navForm.url" type="url" required placeholder="https://navidrome.yourdomain.com" />
</div>
<div class="form-group">
<label>Navidrome Username</label>
<input v-model="navForm.username" type="text" required placeholder="username" />
</div>
<div class="form-group">
<label>Navidrome Password</label>
<input v-model="navForm.password" type="password" required placeholder="••••••••" />
</div>
<div v-if="error" class="error-msg">{{ error }}</div>
<button type="submit" class="submit-btn" :disabled="isLoading">
{{ isLoading ? 'Connecting...' : 'Connect Library' }}
</button>
</form>
<!-- Step 3: Waiting for Analysis -->
<div v-if="step === 3" class="waiting-state">
<div class="spinner"></div>
<p class="step-desc">Your station is syncing tracks from Navidrome and the engine is analyzing the audio profiles. This might take a few minutes.</p>
<div class="progress-info">
<span class="track-count">{{ analyzedTracks }} / 10</span>
<span class="track-label">minimum tracks analyzed</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const emit = defineEmits(['complete'])
const step = ref(1)
const isLoading = ref(false)
const error = ref('')
const adminForm = ref({ username: '', password: '', confirmPassword: '' })
const navForm = ref({ url: '', username: '', password: '' })
const analyzedTracks = ref(0)
let pollInterval = null
const checkStatus = async () => {
try {
const res = await fetch('/api/setup/status')
if (res.ok) {
const data = await res.json()
analyzedTracks.value = data.analyzed_tracks
if (data.is_setup) {
emit('complete')
} else if (data.admin_setup && data.navidrome_setup) {
step.value = 3
} else if (data.admin_setup) {
step.value = 2
} else {
step.value = 1
}
}
} catch (err) {
console.error('Failed to check setup status', err)
}
}
onMounted(() => {
checkStatus()
pollInterval = setInterval(checkStatus, 3000)
})
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval)
})
const submitAdmin = async () => {
isLoading.value = true
error.value = ''
if (adminForm.value.password !== adminForm.value.confirmPassword) {
error.value = 'Passwords do not match'
isLoading.value = false
return
}
try {
const res = await fetch('/api/setup/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(adminForm.value)
})
if (res.ok) {
step.value = 2
} else {
const data = await res.json()
error.value = data.detail || 'Failed to setup admin'
}
} catch (err) {
error.value = 'Network error'
} finally {
isLoading.value = false
}
}
const submitNavidrome = async () => {
isLoading.value = true
error.value = ''
try {
const res = await fetch('/api/setup/navidrome', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(navForm.value)
})
if (res.ok) {
step.value = 3
} else {
const data = await res.json()
error.value = data.detail || 'Failed to connect Navidrome'
}
} catch (err) {
error.value = 'Network error'
} finally {
isLoading.value = false
}
}
</script>
<style scoped>
.setup-container {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: radial-gradient(circle at center, #1e1b4b, #000);
font-family: 'Inter', sans-serif;
}
.setup-card {
width: 100%;
max-width: 420px;
padding: 2.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.glass-panel {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.logo-area {
text-align: center;
}
.logo-area h1 {
font-size: 2.5rem;
font-weight: 800;
margin: 0;
background: linear-gradient(135deg, #fff, #c4b5fd);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 2px;
}
.subtitle {
color: #a78bfa;
margin-top: 0.5rem;
font-size: 1rem;
}
.setup-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.step-desc {
color: #d1d5db;
font-size: 0.95rem;
line-height: 1.5;
text-align: center;
margin-bottom: 0.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: #e5e7eb;
text-transform: uppercase;
letter-spacing: 1px;
}
input {
width: 100%;
padding: 0.85rem 1rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: white;
font-size: 1rem;
transition: all 0.2s ease;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: #8b5cf6;
background: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
}
.submit-btn {
width: 100%;
padding: 1rem;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
color: white;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 1rem;
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(139, 92, 246, 0.3);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-msg {
color: #fca5a5;
font-size: 0.9rem;
text-align: center;
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
}
.waiting-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
padding: 2rem 0;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(139, 92, 246, 0.2);
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.progress-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.track-count {
font-size: 2.5rem;
font-weight: 800;
color: white;
text-shadow: 0 0 20px rgba(139, 92, 246, 0.5);
}
.track-label {
color: #a78bfa;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 1px;
}
</style>
+315
View File
@@ -0,0 +1,315 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as THREE from 'three'
import { settingsState, THEME_PALETTE } from '../store/settings'
const props = defineProps({
isPlaying: {
type: Boolean,
default: false
},
isLoading: {
type: Boolean,
default: false
},
bpm: {
type: Number,
default: 120
},
intensity: {
type: Number,
default: 0.6
},
brightness: {
type: Number,
default: 0.6
}
})
const canvasRef = ref(null)
let renderer, scene, camera, mesh, material
let animationFrameId
let clock
const initThree = () => {
if (!canvasRef.value) return
// Scene setup
scene = new THREE.Scene()
// Camera setup
const width = canvasRef.value.clientWidth || 100
const height = canvasRef.value.clientHeight || 100
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100)
camera.position.z = 6
// Renderer setup
renderer = new THREE.WebGLRenderer({
canvas: canvasRef.value,
antialias: true,
alpha: true // Enables transparent background
})
renderer.setSize(width, height, false)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
// Geometry
const geometry = new THREE.SphereGeometry(1.6, 64, 64)
// Custom Shader Material for glowing organic plasma effect
clock = new THREE.Clock()
material = new THREE.ShaderMaterial({
vertexShader: `
uniform float uTime;
uniform float uNoiseStrength;
varying vec3 vNormal;
varying vec3 vPosition;
// Symmetrical sine-based organic noise for displacement
float getNoise(vec3 p) {
return sin(p.x * 2.5 + uTime * 1.5) *
cos(p.y * 2.5 + uTime * 1.2) *
sin(p.z * 2.5 + uTime * 1.8);
}
void main() {
vNormal = normalize(normalMatrix * normal);
vPosition = position;
float displacement = getNoise(position) * uNoiseStrength;
vec3 newPosition = position + normal * displacement;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
`,
fragmentShader: `
uniform float uTime;
uniform float uBrightness;
uniform vec3 uColorGlow;
uniform vec3 uColorAccent;
uniform vec3 uColorDark;
varying vec3 vNormal;
varying vec3 vPosition;
void main() {
// Fresnel effect for glowing outline
vec3 n = normalize(vNormal);
float intensity = 1.0 - max(dot(n, vec3(0.0, 0.0, 1.0)), 0.0);
vec3 colorWhite = vec3(1.0, 1.0, 1.0);
// Base core gets brighter/whiter with track brightness
vec3 coreColor = mix(uColorGlow, colorWhite, uBrightness * 0.35);
// Shifting color gradient across position and time
float mixVal = sin(vPosition.x * 1.8 + uTime) * 0.5 + 0.5;
vec3 baseColor = mix(coreColor, uColorAccent, mixVal);
baseColor = mix(baseColor, uColorDark, sin(vPosition.y * 1.5 - uTime * 0.8) * (0.2 + uBrightness * 0.3));
// Glowing rim highlight scaled by brightness
float glowFactor = 1.2 + uBrightness * 2.2;
float glow = pow(intensity, 2.8) * glowFactor;
vec3 finalColor = baseColor + vec3(glow) * uColorGlow;
// Core highlight for glass-like reflection
float core = max(dot(n, vec3(0.0, 0.0, 1.0)), 0.0);
finalColor += vec3(pow(core, 5.0) * (0.35 + uBrightness * 0.65));
gl_FragColor = vec4(finalColor, 0.85);
}
`,
uniforms: {
uTime: { value: 0 },
uNoiseStrength: { value: 0.08 },
uBrightness: { value: 0.6 },
uColorGlow: { value: new THREE.Color((THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple).glow) },
uColorAccent: { value: new THREE.Color((THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple).accent) },
uColorDark: { value: new THREE.Color((THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple).dark) }
},
transparent: true,
depthWrite: true,
depthTest: true
})
mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
// Adjust noise strength initially
updateNoiseStrength()
// Start loop
animate()
}
const updateNoiseStrength = () => {
if (!material) return
let targetStrength = 0.06 // Idle
if (props.isLoading) {
targetStrength = 0.15 // Buffering pulse
} else if (props.isPlaying) {
targetStrength = 0.28 // Playing organic morphing
}
// Transition noise strength smoothly
THREE.MathUtils.lerp(material.uniforms.uNoiseStrength.value, targetStrength, 0.1)
material.uniforms.uNoiseStrength.value = targetStrength
}
// Watch props to update shader parameters
watch(() => [props.isPlaying, props.isLoading, props.intensity, props.brightness], () => {
if (material) {
let targetStrength = 0.06
if (props.isLoading) targetStrength = 0.15
else if (props.isPlaying) targetStrength = 0.06 + props.intensity * 0.24
THREE.MathUtils.lerp(material.uniforms.uNoiseStrength.value, targetStrength, 0.1)
}
})
// Watch theme color to dynamically update orb colors
watch(() => settingsState.themeColor, (newColor) => {
if (material) {
const palette = THEME_PALETTE[newColor] || THEME_PALETTE.purple
material.uniforms.uColorGlow.value.set(palette.glow)
material.uniforms.uColorAccent.value.set(palette.accent)
material.uniforms.uColorDark.value.set(palette.dark)
}
})
const animate = () => {
animationFrameId = requestAnimationFrame(animate)
if (material) {
const elapsedTime = clock.getElapsedTime()
// Playback state controls animation speed multiplier
let speedMult = 0.5
if (props.isLoading) {
speedMult = 2.5
} else if (props.isPlaying) {
// Scale morphing speed based on track BPM
speedMult = 0.8 + (props.bpm / 120) * 0.8
}
material.uniforms.uTime.value = elapsedTime * speedMult
// Smoothly interpolate noise strength
let targetStrength = 0.06
if (props.isLoading) {
targetStrength = 0.16
} else if (props.isPlaying) {
// Scaled by track intensity
targetStrength = 0.06 + props.intensity * 0.22
}
material.uniforms.uNoiseStrength.value = THREE.MathUtils.lerp(
material.uniforms.uNoiseStrength.value,
targetStrength,
0.05
)
// Smoothly interpolate brightness uniform
let targetBrightness = 0.2
if (props.isLoading) {
targetBrightness = 0.6
} else if (props.isPlaying) {
targetBrightness = props.brightness
}
material.uniforms.uBrightness.value = THREE.MathUtils.lerp(
material.uniforms.uBrightness.value,
targetBrightness,
0.05
)
}
if (mesh) {
const elapsedTime = clock.getElapsedTime()
// Slow rotational drift (spins faster for higher intensity/BPM)
let rotationSpeed = 0.004
if (props.isPlaying) {
rotationSpeed = 0.006 + props.intensity * 0.01
}
mesh.rotation.y += rotationSpeed
mesh.rotation.x += rotationSpeed * 0.5
// Thumping pulse matching BPM
let scaleVal = 1.0
if (props.isPlaying) {
const beatDuration = 60 / props.bpm
const beatProgress = (elapsedTime % beatDuration) / beatDuration
// Kick drum pulse curve (sharp punch, exponential decay)
const beatWave = Math.pow(Math.sin(beatProgress * Math.PI), 4)
scaleVal = 1.0 + beatWave * 0.08 * props.intensity
}
mesh.scale.set(scaleVal, scaleVal, scaleVal)
}
renderer.render(scene, camera)
}
let resizeObserver;
const handleResize = () => {
if (!canvasRef.value || !renderer || !camera) return
const width = canvasRef.value.parentElement.clientWidth
const height = canvasRef.value.parentElement.clientHeight
if (width === 0 || height === 0) return
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
}
onMounted(() => {
initThree()
if (canvasRef.value && canvasRef.value.parentElement) {
resizeObserver = new ResizeObserver(handleResize)
resizeObserver.observe(canvasRef.value.parentElement)
}
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
window.removeEventListener('resize', handleResize)
cancelAnimationFrame(animationFrameId)
if (renderer) {
renderer.dispose()
}
if (mesh) {
mesh.geometry.dispose()
mesh.material.dispose()
}
})
</script>
<template>
<div class="orb-container">
<canvas ref="canvasRef" class="orb-canvas"></canvas>
</div>
</template>
<style scoped>
.orb-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.orb-canvas {
width: 100% !important;
height: 100% !important;
outline: none;
}
</style>
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
+173
View File
@@ -0,0 +1,173 @@
import { reactive } from 'vue'
import { settingsState } from './settings'
const streamUrl = `/stream`
export const playerState = reactive({
isPlaying: false,
isLoading: false,
isMuted: false,
volume: 0.8,
error: null,
isStreamOnline: false,
currentTrack: null,
bpm: 120,
intensity: 0.5,
brightness: 0.5
})
let audio = null
let pollInterval = null
// Fetch currently playing track metadata
export const fetchNowPlaying = async () => {
try {
const response = await fetch('/api/now-playing')
if (response.ok) {
playerState.isStreamOnline = true
const data = await response.json()
playerState.currentTrack = data
if (data.listeners !== undefined) {
settingsState.listenerCount = data.listeners
}
if (data.bpm) playerState.bpm = data.bpm
if (data.energy !== undefined) {
playerState.intensity = Math.max(0.2, Math.min(1.0, data.energy))
}
if (data.lufs !== undefined) {
const lufsVal = data.lufs
let b = (lufsVal + 24) / 19
playerState.brightness = Math.max(0.2, Math.min(1.0, b))
}
if (data.history) {
settingsState.history = data.history
}
if (data.server_time) {
settingsState.serverTimeOffset = Math.floor(Date.now() / 1000) - data.server_time
}
} else {
playerState.isStreamOnline = false
}
} catch (err) {
console.error('Failed to fetch now-playing data:', err)
playerState.isStreamOnline = false
}
}
// Initialize and bind audio listeners
const initAudio = () => {
if (audio) return
audio = new Audio()
audio.addEventListener('playing', () => {
playerState.isLoading = false
playerState.isPlaying = true
playerState.error = null
})
audio.addEventListener('waiting', () => {
playerState.isLoading = true
})
audio.addEventListener('error', (e) => {
console.error('Audio element error:', e)
const code = audio.error ? audio.error.code : 'unknown'
let msg = `Stream offline (code: ${code})`
if (code === 4) msg = `Stream offline or unsupported format.`
if (code === 2) msg = `Network error connecting to stream.`
playerState.error = msg
playerState.isLoading = false
playerState.isPlaying = false
cleanupAudio()
})
}
// Cleanup audio resource
export const cleanupAudio = () => {
if (audio) {
try {
audio.pause()
audio.removeAttribute('src')
audio.load()
} catch (e) {
console.error('Error during cleanup:', e)
}
audio = null
}
}
// Toggle Play / Pause
export const togglePlay = () => {
if (playerState.isPlaying) {
pauseStream()
} else {
playStream()
}
}
// Play stream (dynamically loading it to get fresh live feed)
export const playStream = async () => {
playerState.error = null
playerState.isLoading = true
try {
initAudio()
// Append timestamp to bypass browser caching of dead stream connections
audio.src = `${streamUrl}?cb=${Date.now()}`
audio.volume = playerState.isMuted ? 0 : playerState.volume
audio.load()
await audio.play()
} catch (err) {
console.error('Playback failed:', err)
playerState.error = `Playback blocked (${err.name || 'unknown error'})`
playerState.isLoading = false
playerState.isPlaying = false
cleanupAudio()
}
}
// Pause stream (unloads stream to save bandwidth and keep live alignment)
export const pauseStream = () => {
playerState.isPlaying = false
playerState.isLoading = false
cleanupAudio()
}
// Toggle Mute
export const toggleMute = () => {
playerState.isMuted = !playerState.isMuted
if (audio) {
audio.volume = playerState.isMuted ? 0 : playerState.volume
}
}
// Set Volume directly
export const setVolume = (newVal) => {
playerState.volume = newVal
if (audio) {
audio.volume = playerState.isMuted ? 0 : newVal
}
}
// Start Background Polling (Called exactly once in App.vue or AudioPlayer)
export const startPolling = () => {
fetchNowPlaying()
if (!pollInterval) {
pollInterval = setInterval(fetchNowPlaying, 5000)
}
}
export const stopPolling = () => {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
+146
View File
@@ -0,0 +1,146 @@
import { reactive } from 'vue'
export const settingsState = reactive({
themeColor: 'purple',
appTitle: 'NAVI.FM',
appTagline: 'The least intelligent radio software youll ever love.',
infoModalText: '',
artistSeparationEnabled: false,
artistSeparationCount: 5,
faviconSvgOverlay: '',
faviconSvgColor: 'white',
autoPilotSchedule: [],
history: [],
listenerCount: 0,
isLoading: false,
error: null,
serverTimeOffset: 0
})
// The 8 primary theme colors available for branding
export const THEME_PALETTE = {
purple: {
glow: '#8b5cf6',
accent: '#c4b5fd',
dark: '#4c1d95'
},
blue: {
glow: '#3b82f6',
accent: '#93c5fd',
dark: '#1e3a8a'
},
cyan: {
glow: '#06b6d4',
accent: '#67e8f9',
dark: '#164e63'
},
green: {
glow: '#10b981',
accent: '#6ee7b7',
dark: '#064e3b'
},
yellow: {
glow: '#eab308',
accent: '#fde047',
dark: '#713f12'
},
orange: {
glow: '#f97316',
accent: '#fdba74',
dark: '#7c2d12'
},
red: {
glow: '#ef4444',
accent: '#fca5a5',
dark: '#7f1d1d'
},
pink: {
glow: '#ec4899',
accent: '#f9a8d4',
dark: '#831843'
}
}
export const fetchSettings = async () => {
settingsState.isLoading = true
try {
const res = await fetch('/api/settings')
if (res.ok) {
const data = await res.json()
settingsState.themeColor = data.theme_color || 'purple'
settingsState.appTitle = data.app_title || 'NAVI.FM'
settingsState.appTagline = data.app_tagline || ''
settingsState.infoModalText = data.info_modal_text || ''
settingsState.faviconSvgOverlay = data.favicon_svg_overlay || ''
settingsState.faviconSvgColor = data.favicon_svg_color || 'white'
settingsState.artistSeparationEnabled = !!data.artist_separation_enabled
settingsState.artistSeparationCount = data.artist_separation_count || 5
try {
settingsState.autoPilotSchedule = JSON.parse(data.auto_pilot_schedule || '[]')
} catch (e) {
settingsState.autoPilotSchedule = []
}
}
} catch (err) {
console.error('Failed to fetch settings:', err)
} finally {
settingsState.isLoading = false
}
}
export const updateSettings = async (token, newSettings) => {
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${token}`
},
body: JSON.stringify({
theme_color: newSettings.themeColor,
app_title: newSettings.appTitle,
app_tagline: newSettings.appTagline,
info_modal_text: newSettings.infoModalText,
favicon_svg_overlay: newSettings.faviconSvgOverlay,
favicon_svg_color: newSettings.faviconSvgColor,
artist_separation_enabled: newSettings.artistSeparationEnabled,
artist_separation_count: newSettings.artistSeparationCount,
auto_pilot_schedule: JSON.stringify(newSettings.autoPilotSchedule || [])
})
})
if (res.ok) {
// Immediately reflect changes in frontend store
settingsState.themeColor = newSettings.themeColor
settingsState.appTitle = newSettings.appTitle
settingsState.appTagline = newSettings.appTagline
settingsState.infoModalText = newSettings.infoModalText
settingsState.faviconSvgOverlay = newSettings.faviconSvgOverlay
settingsState.faviconSvgColor = newSettings.faviconSvgColor
settingsState.artistSeparationEnabled = newSettings.artistSeparationEnabled
settingsState.artistSeparationCount = newSettings.artistSeparationCount
settingsState.autoPilotSchedule = newSettings.autoPilotSchedule
return { success: true }
}
return { success: false, status: res.status }
} catch (err) {
console.error('Failed to update settings:', err)
return { success: false, error: err.message }
}
}
export const fetchHistory = async (token) => {
try {
const res = await fetch('/api/history', {
headers: {
'Authorization': `Basic ${token}`
}
})
if (res.ok) {
const data = await res.json()
settingsState.history = data.history || []
}
} catch (err) {
console.error('Failed to fetch history:', err)
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
base: './',
})