Initial commit - Navi.FM v3
This commit is contained in:
@@ -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?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Generated
+1130
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const settingsState = reactive({
|
||||
themeColor: 'purple',
|
||||
appTitle: 'NAVI.FM',
|
||||
appTagline: 'The least intelligent radio software you’ll 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './',
|
||||
})
|
||||
Reference in New Issue
Block a user