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
Vendored
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/coliefm-v3.iml" filepath="$PROJECT_DIR$/.idea/coliefm-v3.iml" />
</modules>
</component>
</project>
+68
View File
@@ -0,0 +1,68 @@
# ---- Stage 1: Build Frontend ----
FROM node:22-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend .
RUN npm run build
# ---- Stage 2: Final Image ----
FROM savonet/liquidsoap:v2.2.5
# Switch to root to install packages
USER root
# Install required packages
# - icecast2: streaming server
# - supervisor: process manager
# - python3-venv, python3-pip: for the backend
# - ffmpeg, libsndfile1: backend audio processing
RUN apt-get update && apt-get install -y \
icecast2 \
supervisor \
python3 \
python3-venv \
python3-pip \
ffmpeg \
libsndfile1 \
&& rm -rf /var/lib/apt/lists/*
# Set up Icecast directories and permissions
RUN mkdir -p /var/log/icecast2 /etc/icecast2 /var/run/icecast2 \
&& chown -R liquidsoap:liquidsoap /var/log/icecast2 /etc/icecast2 /var/run/icecast2
COPY icecast.xml /etc/icecast2/icecast.xml
# Prepare python environment
WORKDIR /app
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY app/requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
COPY app /app/app
# Copy liquidsoap config
COPY liquidsoap /app/liquidsoap
# Copy built frontend from Stage 1
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
# Copy supervisord config
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Give liquidsoap user permissions to /app and /data
RUN mkdir -p /data \
&& chown -R liquidsoap:liquidsoap /app /data /opt/venv
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose required ports: 8080 (API/Web UI), 8000 (Icecast)
EXPOSE 8080 8000
# Start via entrypoint (runs as root to fix permissions, then starts supervisord)
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
import librosa
# Monkeypatch for pyloudnorm which uses the deprecated scipy.signal.hann
import scipy.signal
import scipy.signal.windows
if not hasattr(scipy.signal, 'hann'):
scipy.signal.hann = scipy.signal.windows.hann
import pyloudnorm as pyln
import numpy as np
def analyze_audio(file_path: str) -> dict:
"""Analyzes an audio file and returns metadata."""
try:
# Load audio (mono, original sample rate to preserve frequency content)
y, sr = librosa.load(file_path, sr=None, mono=True)
# BPM
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
if isinstance(tempo, np.ndarray):
bpm = float(tempo[0])
else:
bpm = float(tempo)
# Key extraction using chroma (simplified root note extraction)
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
key_idx = np.argmax(np.sum(chroma, axis=1))
keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
key = keys[key_idx]
# Energy (RMS)
rms = librosa.feature.rms(y=y)
energy = float(np.mean(rms))
# Spectral Centroid (Brightness)
cent = librosa.feature.spectral_centroid(y=y, sr=sr)
spectral_centroid = float(np.mean(cent))
# Onset Density (Danceability/Rhythm)
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
onsets = librosa.onset.onset_detect(onset_envelope=onset_env, sr=sr)
duration_sec = len(y) / sr
onset_density = len(onsets) / duration_sec if duration_sec > 0 else 0
# LUFS
meter = pyln.Meter(sr) # create BS.1770 meter
lufs = meter.integrated_loudness(y)
return {
"bpm": bpm,
"key": key,
"energy": energy,
"spectral_centroid": spectral_centroid,
"onset_density": float(onset_density),
"lufs": float(lufs)
}
except Exception as e:
print(f"Error analyzing {file_path}: {e}")
return None
+27
View File
@@ -0,0 +1,27 @@
import os
from cryptography.fernet import Fernet, InvalidToken
def get_fernet():
key = os.environ.get("ENCRYPTION_KEY")
if not key:
# Fallback to an ephemeral key if missing to prevent crashing,
# though data encrypted with this will be lost on restart
key = Fernet.generate_key()
os.environ["ENCRYPTION_KEY"] = key.decode('utf-8')
return Fernet(key)
def encrypt_password(password: str) -> str:
if not password:
return password
f = get_fernet()
return f.encrypt(password.encode('utf-8')).decode('utf-8')
def decrypt_password(token: str) -> str:
if not token:
return token
f = get_fernet()
try:
return f.decrypt(token.encode('utf-8')).decode('utf-8')
except InvalidToken:
# If decryption fails, assume it's a legacy plaintext password
return token
+69
View File
@@ -0,0 +1,69 @@
import os
from sqlalchemy import create_engine, Column, String, Float, Boolean, Integer
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker
DATA_DIR = os.getenv("DATA_DIR", "/data")
SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DATA_DIR, 'navifm.db')}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
Base = declarative_base()
class Track(Base):
__tablename__ = "tracks"
id = Column(String, primary_key=True, index=True) # Navidrome Track ID
title = Column(String, index=True)
artist = Column(String, index=True)
album = Column(String)
duration = Column(Integer)
path = Column(String) # Navidrome file path or relative path
# Analysis Status
analyzed = Column(Boolean, default=False)
# Analyzed Metadata
bpm = Column(Float, nullable=True)
key = Column(String, nullable=True)
lufs = Column(Float, nullable=True)
energy = Column(Float, nullable=True)
spectral_centroid = Column(Float, nullable=True)
onset_density = Column(Float, nullable=True)
# Play stats
last_played = Column(Integer, default=0) # timestamp
play_count = Column(Integer, default=0)
class Setting(Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(String)
def get_setting(db, key: str, default: str = None):
setting = db.query(Setting).filter(Setting.key == key).first()
return setting.value if setting else default
def set_setting(db, key: str, value: str):
setting = db.query(Setting).filter(Setting.key == key).first()
if setting:
setting.value = value
else:
setting = Setting(key=key, value=value)
db.add(setting)
db.commit()
def init_db():
os.makedirs(DATA_DIR, exist_ok=True)
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+520
View File
@@ -0,0 +1,520 @@
import os
import time
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import PlainTextResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets
import asyncio
from pydantic import BaseModel
import httpx
from fastapi.responses import StreamingResponse
from database import init_db, SessionLocal, Track, get_setting, set_setting
from worker import start_workers
from queue_manager import get_active_profile, set_active_profile, get_next_track, AVAILABLE_PROFILES
from navidrome import navidrome_client
import bcrypt
import crypto
# Initialize DB and start workers on startup
init_db()
db = SessionLocal()
try:
if get_setting(db, "theme_color") is None:
set_setting(db, "theme_color", "purple")
if get_setting(db, "app_title") is None:
set_setting(db, "app_title", "NAVI.FM")
if get_setting(db, "app_tagline") is None:
set_setting(db, "app_tagline", "The least intelligent radio software youll ever love.")
if get_setting(db, "info_modal_text") is None:
set_setting(db, "info_modal_text", "Navi.FM is an independent radio project that completely ditches traditional, static playlists. Instead, it relies on a custom, lightweight engine to intelligently shuffle and balance the unique mood, texture, and energy of every single track inside a privately curated, hand-picked music library.\n\nHow it Works\nInstead of just playing songs at random, Navi.FM uses smart audio analysis to understand how different tracks relate to each other and maps out a seamless, organic journey from one song to the next. It constantly schedules the upcoming tracks in real-time, steering the soundscape to keep the stream flowing perfectly.\n\nBehind the Scenes\nThere are no big media companies or human radio hosts pulling the strings. The entire station is a custom-built passion project run entirely out of a home setup.")
if get_setting(db, "artist_separation_enabled") is None:
set_setting(db, "artist_separation_enabled", "false")
if get_setting(db, "artist_separation_count") is None:
set_setting(db, "artist_separation_count", "5")
# Migration: Encrypt legacy plaintext Navidrome passwords
existing_nav_pwd = get_setting(db, "navidrome_password")
if existing_nav_pwd:
# Fernet tokens start with gAAAAA
if not existing_nav_pwd.startswith("gAAAAA"):
print("Migrating legacy plaintext Navidrome password to Fernet encryption...")
set_setting(db, "navidrome_password", crypto.encrypt_password(existing_nav_pwd))
finally:
db.close()
start_workers()
app = FastAPI(title="Navi.FM Backend")
security = HTTPBasic(auto_error=False)
FAILED_LOGIN_ATTEMPTS = {}
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION = 300 # 5 minutes
def verify_admin(request: Request, credentials: HTTPBasicCredentials = Depends(security)):
client_ip = request.client.host
now = time.time()
# Check rate limits
if client_ip in FAILED_LOGIN_ATTEMPTS:
FAILED_LOGIN_ATTEMPTS[client_ip] = [t for t in FAILED_LOGIN_ATTEMPTS[client_ip] if now - t < LOCKOUT_DURATION]
if len(FAILED_LOGIN_ATTEMPTS[client_ip]) >= MAX_LOGIN_ATTEMPTS:
raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.")
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
db = SessionLocal()
try:
admin_user = get_setting(db, "admin_user")
admin_pass_hash = get_setting(db, "admin_password_hash")
finally:
db.close()
if not admin_user or not admin_pass_hash:
raise HTTPException(status_code=401, detail="Admin not setup")
correct_username = secrets.compare_digest(credentials.username, admin_user)
# Use bcrypt to verify password
try:
correct_password = bcrypt.checkpw(
credentials.password.encode('utf-8'),
admin_pass_hash.encode('utf-8')
)
except Exception:
correct_password = False
if not (correct_username and correct_password):
FAILED_LOGIN_ATTEMPTS.setdefault(client_ip, []).append(now)
raise HTTPException(
status_code=401,
detail="Incorrect username or password",
)
# Clear attempts on success
if client_ip in FAILED_LOGIN_ATTEMPTS:
del FAILED_LOGIN_ATTEMPTS[client_ip]
return credentials.username
class AdminSetup(BaseModel):
username: str
password: str
class NavidromeSetup(BaseModel):
url: str
username: str
password: str
@app.get("/api/setup/status")
async def get_setup_status():
db = SessionLocal()
try:
admin_setup = get_setting(db, "admin_user") is not None
nav_setup = get_setting(db, "navidrome_url") is not None
analyzed_tracks = db.query(Track).filter(Track.analyzed == True).count()
return {
"is_setup": admin_setup and nav_setup and analyzed_tracks > 10,
"admin_setup": admin_setup,
"navidrome_setup": nav_setup,
"analyzed_tracks": analyzed_tracks
}
finally:
db.close()
@app.post("/api/setup/admin")
async def setup_admin(data: AdminSetup):
db = SessionLocal()
try:
if get_setting(db, "admin_user"):
raise HTTPException(status_code=400, detail="Admin already setup")
hashed_password = bcrypt.hashpw(data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
set_setting(db, "admin_user", data.username)
set_setting(db, "admin_password_hash", hashed_password)
return {"status": "success"}
finally:
db.close()
@app.post("/api/setup/navidrome")
async def setup_navidrome(data: NavidromeSetup):
db = SessionLocal()
try:
if get_setting(db, "navidrome_url"):
raise HTTPException(status_code=400, detail="Navidrome already setup")
from navidrome import NavidromeClient
is_valid = NavidromeClient.verify_credentials(data.url, data.username, data.password)
if not is_valid:
raise HTTPException(status_code=400, detail="Could not connect to Navidrome server. Please check your URL and credentials.")
set_setting(db, "navidrome_url", data.url.rstrip("/"))
set_setting(db, "navidrome_user", data.username)
set_setting(db, "navidrome_password", crypto.encrypt_password(data.password))
return {"status": "success"}
finally:
db.close()
class ProfileUpdate(BaseModel):
profile: str
@app.get("/api/profiles")
async def get_profiles(_: str = Depends(verify_admin)):
db = SessionLocal()
try:
active_profile = get_active_profile(db)
track_count = db.query(Track).filter(Track.analyzed == True).count()
total_track_count = navidrome_client.get_total_songs()
finally:
db.close()
return {
"profiles": AVAILABLE_PROFILES,
"active_profile": active_profile,
"track_count": track_count,
"total_track_count": total_track_count
}
@app.post("/api/profile")
async def update_profile(data: ProfileUpdate, _: str = Depends(verify_admin)):
valid_ids = [p["id"] for p in AVAILABLE_PROFILES]
if data.profile not in valid_ids:
raise HTTPException(status_code=400, detail="Invalid profile")
set_active_profile(data.profile)
return {"status": "success", "profile": data.profile}
class SettingsUpdate(BaseModel):
theme_color: str
app_title: str
app_tagline: str
info_modal_text: str
favicon_svg_overlay: str = ""
favicon_svg_color: str = "white"
artist_separation_enabled: bool = False
artist_separation_count: int = 5
auto_pilot_schedule: str = "[]"
@app.get("/api/settings")
async def read_settings():
db = SessionLocal()
try:
return {
"theme_color": get_setting(db, "theme_color", "purple"),
"app_title": get_setting(db, "app_title", "COLIE.FM"),
"app_tagline": get_setting(db, "app_tagline", ""),
"info_modal_text": get_setting(db, "info_modal_text", ""),
"favicon_svg_overlay": get_setting(db, "favicon_svg_overlay", ""),
"favicon_svg_color": get_setting(db, "favicon_svg_color", "white"),
"artist_separation_enabled": get_setting(db, "artist_separation_enabled", "false") == "true",
"artist_separation_count": int(get_setting(db, "artist_separation_count", "5")),
"auto_pilot_schedule": get_setting(db, "auto_pilot_schedule", "[]")
}
finally:
db.close()
@app.post("/api/settings")
async def update_settings(data: SettingsUpdate, _: str = Depends(verify_admin)):
db = SessionLocal()
try:
set_setting(db, "theme_color", data.theme_color)
set_setting(db, "app_title", data.app_title)
set_setting(db, "app_tagline", data.app_tagline)
set_setting(db, "info_modal_text", data.info_modal_text)
set_setting(db, "favicon_svg_overlay", data.favicon_svg_overlay)
set_setting(db, "favicon_svg_color", data.favicon_svg_color)
set_setting(db, "artist_separation_enabled", "true" if data.artist_separation_enabled else "false")
set_setting(db, "artist_separation_count", str(data.artist_separation_count))
set_setting(db, "auto_pilot_schedule", data.auto_pilot_schedule)
finally:
db.close()
return {"status": "success"}
@app.get("/api/history")
async def get_history(_: str = Depends(verify_admin)):
"""
Returns the last 10 tracks played for the admin/debug panel.
"""
now_data = await now_playing()
return {"history": now_data.get("history", [])}
import socket
@app.post("/api/skip")
async def skip_track(_: str = Depends(verify_admin)):
"""
Sends a telnet command to Liquidsoap to skip the current track.
"""
try:
# Connect to liquidsoap telnet server
with socket.create_connection(("127.0.0.1", 1234), timeout=2) as sock:
# Send the skip command for the navifm_queue output node
sock.sendall(b"navifm_queue.skip\r\nquit\r\n")
# Read the 'Done' response to ensure it was processed
sock.recv(4096)
return {"status": "success", "message": "Track skipped"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to skip track: {str(e)}")
@app.get("/next", response_class=PlainTextResponse)
async def next_track(request: Request):
"""
Called by Liquidsoap to get the URL of the next track to play.
Restricted to localhost for security.
"""
if request.client.host != "127.0.0.1":
raise HTTPException(status_code=403, detail="Forbidden: Internal endpoint")
track_data = get_next_track()
if track_data:
# Generate the stream URL from Navidrome
url = navidrome_client.get_stream_url(track_data["id"])
lufs = track_data.get("lufs")
if lufs is not None:
# Calculate ratio to hit target of -14 LUFS (common streaming standard)
target_lufs = -14.0
delta_db = target_lufs - lufs
# Cap the adjustment between -15dB and +10dB to avoid extreme boosting
delta_db = min(max(delta_db, -15.0), 10.0)
ratio = 10 ** (delta_db / 20.0)
# Use Liquidsoap's annotate protocol to pass the custom amplify metadata
return f'annotate:liq_amplify="{ratio:.4f}":{url}'
return url
else:
# Return empty or some fallback if queue is entirely empty
# Sleep for 5 seconds to prevent liquidsoap from looping instantly and spamming logs
await asyncio.sleep(5)
return ""
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.get("/stream")
async def proxy_stream(request: Request):
"""
Proxies the Icecast audio stream so the frontend doesn't need to connect to port 8000 directly.
"""
client = httpx.AsyncClient(timeout=None)
# Pre-flight request to get headers
req = client.build_request("GET", "http://127.0.0.1:8000/stream")
response = await client.send(req, stream=True)
headers = {}
for k, v in response.headers.items():
if k.lower().startswith('icy-') or k.lower() == 'content-type':
headers[k] = v
async def stream_generator():
try:
async for chunk in response.aiter_bytes():
if await request.is_disconnected():
break
yield chunk
except Exception:
pass
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(stream_generator(), headers=headers)
import time
current_playing_state = {
"rid": None,
"start_time": int(time.time()),
"track_dict": None,
"history": None
}
@app.get("/api/internal/track_changed")
async def track_changed(request: Request, title: str = None, artist: str = None):
"""
Webhook called by Liquidsoap exactly when a new track starts playing.
Restricted to localhost for security.
"""
if request.client.host != "127.0.0.1":
raise HTTPException(status_code=403, detail="Forbidden: Internal endpoint")
global current_playing_state
# 1. Pop previous track into history
if current_playing_state["track_dict"] and current_playing_state["track_dict"].get("title") != "Unknown":
old_track = current_playing_state["track_dict"].copy()
old_track["last_played"] = int(time.time())
if current_playing_state["history"] is None:
current_playing_state["history"] = []
current_playing_state["history"].insert(0, old_track)
if len(current_playing_state["history"]) > 10:
current_playing_state["history"].pop()
# 2. Fetch new track metadata from DB
db = SessionLocal()
try:
if title and artist:
track = db.query(Track).filter(Track.title == title, Track.artist == artist).first()
if track:
current_playing_state["track_dict"] = {
"id": track.id,
"title": track.title,
"artist": track.artist,
"bpm": track.bpm,
"energy": track.energy,
"lufs": track.lufs
}
else:
current_playing_state["track_dict"] = {"id": "", "title": title, "artist": artist, "bpm": 120, "energy": 0.5, "lufs": -14.0}
else:
current_playing_state["track_dict"] = {"id": "", "title": "Unknown", "artist": "Unknown", "bpm": 120, "energy": 0.5, "lufs": -14.0}
current_playing_state["start_time"] = int(time.time())
finally:
db.close()
return {"status": "ok"}
@app.get("/api/now-playing")
async def now_playing():
"""
Returns the metadata of the currently playing track for the frontend UI.
"""
global current_playing_state
# If UI requests before first track_changed webhook hits, return a fallback
if not current_playing_state["track_dict"]:
db = SessionLocal()
try:
from queue_manager import get_current_track
current = get_current_track()
if current:
current_playing_state["track_dict"] = current
else:
current_playing_state["track_dict"] = {"id": "", "title": "Unknown", "artist": "Unknown", "bpm": 120, "energy": 0.5, "lufs": -14.0}
finally:
db.close()
# Seed history from DB if empty on first boot
if current_playing_state["history"] is None:
db = SessionLocal()
try:
recent_tracks = db.query(Track).filter(Track.last_played > 0).order_by(Track.last_played.desc()).limit(15).all()
h_list = []
v_time = int(time.time())
# Very rough approximation just so UI isn't empty on boot
for t in recent_tracks[:10]:
if t.title != current_playing_state["track_dict"].get("title"):
h_list.append({
"id": t.id,
"title": t.title,
"artist": t.artist,
"last_played": v_time
})
v_time -= (t.duration if t.duration else 180)
current_playing_state["history"] = h_list
finally:
db.close()
# Return state
res = current_playing_state["track_dict"].copy()
res["history"] = current_playing_state["history"]
res["server_time"] = int(time.time())
return res
from fastapi.staticfiles import StaticFiles
import os
import re
import time
from fastapi.responses import HTMLResponse, Response
frontend_dist = "/app/frontend/dist"
@app.get("/favicon.svg")
async def serve_favicon():
db = SessionLocal()
try:
theme_color = get_setting(db, "theme_color", "purple")
overlay = get_setting(db, "favicon_svg_overlay", "")
svg_color = get_setting(db, "favicon_svg_color", "white")
finally:
db.close()
palette = {
"purple": {"glow": "#8b5cf6"},
"blue": {"glow": "#3b82f6"},
"cyan": {"glow": "#06b6d4"},
"green": {"glow": "#10b981"},
"yellow": {"glow": "#eab308"},
"orange": {"glow": "#f97316"},
"red": {"glow": "#ef4444"},
"pink": {"glow": "#ec4899"}
}
glow = palette.get(theme_color, palette["purple"])["glow"]
overlay_content = ""
if overlay:
# Remove hardcoded width/height
processed = re.sub(r'\b(width|height)=("[^"]*"|\'[^\']*\')', '', overlay, flags=re.IGNORECASE)
processed = re.sub(r'<svg\b', '<svg width="100%" height="100%" ', processed, flags=re.IGNORECASE)
overlay_content = f'<svg x="20" y="20" width="60" height="60" class="overlay-container">{processed}</svg>'
svg_string = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" style="background: transparent; color: {svg_color};">
<style>
.overlay-container path:not([fill="none"]),
.overlay-container rect:not([fill="none"]),
.overlay-container circle:not([fill="none"]),
.overlay-container polygon:not([fill="none"]),
.overlay-container polyline:not([fill="none"]),
.overlay-container ellipse:not([fill="none"]) {{
fill: {svg_color} !important;
}}
.overlay-container [stroke]:not([stroke="none"]) {{
stroke: {svg_color} !important;
}}
</style>
<circle cx="50" cy="50" r="50" fill="{glow}" />
{overlay_content}
</svg>'''
return Response(content=svg_string, media_type="image/svg+xml", headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
@app.get("/")
async def serve_index():
index_path = os.path.join(frontend_dist, "index.html")
if not os.path.exists(index_path):
return HTMLResponse("Frontend not built")
db = SessionLocal()
try:
app_title = get_setting(db, "app_title", "NAVI.FM")
finally:
db.close()
with open(index_path, "r") as f:
html = f.read()
# Replace title
html = re.sub(r"<title>.*?</title>", f"<title>{app_title}</title>", html)
# Inject favicon link
cache_bust = int(time.time())
favicon_link = f'<link rel="icon" href="/favicon.svg?v={cache_bust}" type="image/svg+xml" />'
html = html.replace("</head>", f" {favicon_link}\n</head>")
return HTMLResponse(content=html)
# Serve the Vue frontend built files if they exist
if os.path.exists(frontend_dist):
# Mount frontend dist at root but do not serve index.html directly for /
app.mount("/", StaticFiles(directory=frontend_dist, html=False), name="static")
+153
View File
@@ -0,0 +1,153 @@
import os
import hashlib
import random
import string
import requests
from typing import List, Dict
class NavidromeClient:
def __init__(self):
self.version = "1.16.1"
self.client_name = "navifm"
@staticmethod
def verify_credentials(url: str, username: str, password: str) -> bool:
salt = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(6))
token = hashlib.md5((password + salt).encode('utf-8')).hexdigest()
params = {
"u": username,
"t": token,
"s": salt,
"v": "1.16.1",
"c": "navifm",
"f": "json"
}
try:
url_clean = url.rstrip('/')
response = requests.get(f"{url_clean}/rest/ping", params=params, timeout=10)
response.raise_for_status()
data = response.json()
if "subsonic-response" in data and data["subsonic-response"]["status"] == "ok":
return True
return False
except Exception as e:
print(f"Credentials verification failed: {e}")
return False
@property
def url(self):
from database import SessionLocal, get_setting
db = SessionLocal()
try:
return get_setting(db, "navidrome_url", "")
finally:
db.close()
@property
def user(self):
from database import SessionLocal, get_setting
db = SessionLocal()
try:
return get_setting(db, "navidrome_user", "")
finally:
db.close()
@property
def password(self):
from database import SessionLocal, get_setting
import crypto
db = SessionLocal()
try:
enc_pwd = get_setting(db, "navidrome_password", "")
return crypto.decrypt_password(enc_pwd)
finally:
db.close()
def _generate_salt(self, length=6):
return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length))
def _get_auth_params(self):
salt = self._generate_salt()
token = hashlib.md5((self.password + salt).encode('utf-8')).hexdigest()
return {
"u": self.user,
"t": token,
"s": salt,
"v": self.version,
"c": self.client_name,
"f": "json"
}
def _request(self, endpoint: str, params: Dict = None):
if not self.url or not self.user or not self.password:
print("Navidrome credentials not configured.")
return None
url = f"{self.url}/rest/{endpoint}"
payload = self._get_auth_params()
if params:
payload.update(params)
try:
response = requests.get(url, params=payload, timeout=10)
response.raise_for_status()
data = response.json()
if "subsonic-response" in data and data["subsonic-response"]["status"] == "ok":
return data["subsonic-response"]
else:
print(f"Subsonic Error: {data}")
return None
except Exception as e:
print(f"Request failed: {e}")
return None
def get_random_songs(self, count=100) -> List[Dict]:
"""Fetch random songs to discover tracks for the library."""
res = self._request("getRandomSongs", {"size": count})
if res and "randomSongs" in res and "song" in res["randomSongs"]:
return res["randomSongs"]["song"]
return []
def get_total_songs(self) -> int:
"""Attempts to get the total number of songs from Navidrome."""
if not self.url or not self.user or not self.password:
return 0
try:
url_clean = self.url.rstrip('/')
# Try native Navidrome API (requires real password, not subsonic token)
login_res = requests.post(f"{url_clean}/auth/login", json={"username": self.user, "password": self.password}, timeout=5)
if login_res.ok:
token = login_res.json().get("token")
if token:
headers = {"x-access-token": token}
song_res = requests.get(f"{url_clean}/api/song?_end=1", headers=headers, timeout=5)
if song_res.ok and "x-total-count" in song_res.headers:
return int(song_res.headers["x-total-count"])
except Exception as e:
print(f"Failed native API count: {e}")
return 0
def get_stream_url(self, song_id: str) -> str:
"""Returns the full authenticated URL to stream a song."""
params = self._get_auth_params()
params["id"] = song_id
# We also might want to specify format, but usually Navidrome handles it
query_string = "&".join(f"{k}={v}" for k, v in params.items())
return f"{self.url}/rest/stream?{query_string}"
def download_song(self, song_id: str, dest_path: str):
"""Downloads a song to disk for analysis."""
stream_url = self.get_stream_url(song_id)
try:
with requests.get(stream_url, stream=True, timeout=30) as r:
r.raise_for_status()
with open(dest_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return True
except Exception as e:
print(f"Failed to download song {song_id}: {e}")
return False
navidrome_client = NavidromeClient()
+111
View File
@@ -0,0 +1,111 @@
import time
import random
from sqlalchemy.sql.expression import func
from database import SessionLocal, Track, Setting
def get_active_profile(db):
setting = db.query(Setting).filter(Setting.key == "active_profile").first()
return setting.value if setting else "harmonic"
def set_active_profile(profile_name: str):
db = SessionLocal()
try:
setting = db.query(Setting).filter(Setting.key == "active_profile").first()
if not setting:
setting = Setting(key="active_profile", value=profile_name)
db.add(setting)
else:
setting.value = profile_name
db.commit()
finally:
db.close()
def get_next_track():
db = SessionLocal()
try:
profile = get_active_profile(db)
track = None
# Only select from analyzed tracks
base_query = db.query(Track).filter(Track.analyzed == True)
# Avoid playing tracks that were played in the last hour
one_hour_ago = int(time.time()) - 3600
pool_query = base_query.filter(Track.last_played < one_hour_ago)
# Artist Separation Logic
artist_sep_enabled = db.query(Setting).filter(Setting.key == "artist_separation_enabled").first()
if artist_sep_enabled and artist_sep_enabled.value == "true":
artist_sep_count_setting = db.query(Setting).filter(Setting.key == "artist_separation_count").first()
sep_count = int(artist_sep_count_setting.value) if artist_sep_count_setting else 5
recent_tracks = db.query(Track).filter(Track.last_played > 0).order_by(Track.last_played.desc()).limit(sep_count).all()
recent_artists = [t.artist for t in recent_tracks if t.artist]
if recent_artists:
filtered_query = pool_query.filter(~Track.artist.in_(recent_artists))
if filtered_query.count() > 0:
pool_query = filtered_query
if pool_query.count() == 0:
# Fallback if all tracks were played recently or no tracks analyzed
pool_query = base_query
if profile == "high_energy":
# Pick from most energetic tracks
tracks = pool_query.order_by(Track.energy.desc()).limit(50).all()
if tracks:
track = random.choice(tracks)
elif profile == "chill":
# Pick from least energetic tracks
tracks = pool_query.order_by(Track.energy.asc()).limit(50).all()
if tracks:
track = random.choice(tracks)
elif profile == "harmonic":
# Get last played track to match key/bpm
last_played = base_query.order_by(Track.last_played.desc()).first()
if last_played and last_played.key and last_played.bpm:
# Find matching key and similar BPM (+- 10)
track = pool_query.filter(Track.key == last_played.key)\
.filter(Track.bpm.between(last_played.bpm - 10, last_played.bpm + 10))\
.order_by(func.random()).first()
# Fallback to random if no track found for profile or profile is 'random'
if not track:
track = pool_query.order_by(func.random()).first()
if track:
# Update last played
track.last_played = int(time.time())
track.play_count += 1
db.commit()
return {"id": track.id, "lufs": track.lufs}
return None
finally:
db.close()
def get_current_track():
db = SessionLocal()
try:
# The currently playing track is approximated as the most recently queued track
track = db.query(Track).order_by(Track.last_played.desc()).first()
if track and track.last_played > 0:
return {
"id": track.id,
"title": track.title,
"artist": track.artist,
"bpm": track.bpm,
"energy": track.energy,
"lufs": track.lufs
}
return None
finally:
db.close()
# Define available profiles for the WebUI
AVAILABLE_PROFILES = [
{"id": "harmonic", "name": "Harmonic Mix", "description": "Attempts to match the key and BPM of the previously played track."},
{"id": "high_energy", "name": "High Energy", "description": "Prioritizes tracks with high RMS energy."},
{"id": "chill", "name": "Chill", "description": "Prioritizes low-energy, ambient tracks."},
{"id": "random", "name": "Random", "description": "Plays tracks randomly."}
]
+13
View File
@@ -0,0 +1,13 @@
fastapi
uvicorn[standard]
requests
httpx
librosa
soundfile
SQLAlchemy
jinja2
python-dotenv
pydantic
pyloudnorm
bcrypt
cryptography
View File
+160
View File
@@ -0,0 +1,160 @@
import time
import os
import threading
import subprocess
from database import SessionLocal, Track
from navidrome import navidrome_client
from analyzer import analyze_audio
from queue_manager import get_active_profile, set_active_profile
import json
import datetime
def sync_tracks_loop():
while True:
try:
if not navidrome_client.url:
time.sleep(10)
continue
print("Syncing tracks from Navidrome...")
songs = navidrome_client.get_random_songs(count=200)
db = SessionLocal()
added = 0
for song in songs:
# Check if song exists
existing = db.query(Track).filter(Track.id == song["id"]).first()
if not existing:
new_track = Track(
id=song["id"],
title=song.get("title", "Unknown"),
artist=song.get("artist", "Unknown"),
album=song.get("album", "Unknown"),
duration=song.get("duration", 0),
path=song.get("path", "")
)
db.add(new_track)
added += 1
if added > 0:
db.commit()
db.close()
print(f"Sync complete. Added {added} new tracks.")
except Exception as e:
print(f"Error syncing tracks: {e}")
# Run every hour
time.sleep(3600)
def analyze_tracks_loop():
while True:
try:
if not navidrome_client.url:
time.sleep(10)
continue
db = SessionLocal()
# Find an un-analyzed track
track = db.query(Track).filter(Track.analyzed == False).first()
if track:
print(f"Analyzing track: {track.artist} - {track.title}")
temp_path = f"/tmp/{track.id}.audio"
# Download
if navidrome_client.download_song(track.id, temp_path):
# Analyze
metadata = analyze_audio(temp_path)
if metadata:
track.bpm = metadata["bpm"]
track.key = metadata["key"]
track.energy = metadata["energy"]
track.spectral_centroid = metadata["spectral_centroid"]
track.onset_density = metadata["onset_density"]
track.lufs = metadata["lufs"]
track.analyzed = True
db.commit()
print("Analysis successful.")
else:
# Mark as analyzed anyway so we don't get stuck in a loop, or maybe add an error flag
track.analyzed = True
db.commit()
print("Analysis failed, marked as analyzed to skip.")
# Cleanup
if os.path.exists(temp_path):
os.remove(temp_path)
else:
print("Download failed.")
else:
# No tracks to analyze, sleep for a while
time.sleep(60)
db.close()
except Exception as e:
print(f"Error in analysis loop: {e}")
time.sleep(60)
def scheduler_loop():
"""
Checks the auto_pilot_schedule every minute and changes the profile if it matches the current time.
"""
while True:
try:
# Wait until the start of the next minute
now = datetime.datetime.now()
sleep_seconds = 60 - now.second
time.sleep(sleep_seconds)
# Now it's a new minute
now = datetime.datetime.now()
current_time_str = now.strftime("%H:%M")
db = SessionLocal()
from database import get_setting
schedule_str = get_setting(db, "auto_pilot_schedule", "[]")
db.close()
schedule = json.loads(schedule_str)
for rule in schedule:
if rule.get("time") == current_time_str:
target_profile = rule.get("profile")
if target_profile:
print(f"[Scheduler] Time is {current_time_str}. Switching profile to: {target_profile}")
set_active_profile(target_profile)
except Exception as e:
print(f"Error in scheduler loop: {e}")
time.sleep(60)
def startup_manager_loop():
"""
Waits until at least 10 tracks have been analyzed before starting Liquidsoap.
This prevents Liquidsoap from rapidly requesting /next and queuing the exact same track
repeatedly when the database is empty or only has 1 track during initial setup.
"""
while True:
try:
db = SessionLocal()
count = db.query(Track).filter(Track.analyzed == True).count()
db.close()
if count >= 10:
print(f"[Startup Manager] {count} tracks analyzed. Starting Liquidsoap...")
# Run supervisorctl to start liquidsoap
subprocess.run(["supervisorctl", "start", "liquidsoap"], check=False)
break
else:
print(f"[Startup Manager] Waiting for initial analysis... ({count}/10 tracks analyzed)")
time.sleep(10)
except Exception as e:
print(f"Error in startup manager: {e}")
time.sleep(10)
def start_workers():
t1 = threading.Thread(target=sync_tracks_loop, daemon=True)
t2 = threading.Thread(target=analyze_tracks_loop, daemon=True)
t3 = threading.Thread(target=scheduler_loop, daemon=True)
t4 = threading.Thread(target=startup_manager_loop, daemon=True)
t1.start()
t2.start()
t3.start()
t4.start()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
services:
navifm:
build:
context: .
dockerfile: Dockerfile
container_name: navifm
ports:
- "8080:8080" # API and Web UI
volumes:
- ./data:/data
environment:
- DATA_DIR=/data
- TZ=Europe/Amsterdam
# If you change ICECAST_PASSWORD, you must also update icecast.xml!
- ICECAST_PASSWORD=hackme
restart: unless-stopped
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
set -e
# Generate or load secure Icecast password
if [ -n "$ICECAST_PASSWORD" ] && [ "$ICECAST_PASSWORD" != "hackme" ]; then
echo "Using user-provided Icecast password."
echo "$ICECAST_PASSWORD" > /data/.icecast_password
elif [ ! -f /data/.icecast_password ]; then
echo "Generating secure Icecast password..."
# Generate a 24-character random alphanumeric password
cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 24 | head -n 1 > /data/.icecast_password
fi
export ICECAST_PASSWORD=$(cat /data/.icecast_password)
# Inject password into icecast.xml
sed -i "s/<source-password>.*<\/source-password>/<source-password>${ICECAST_PASSWORD}<\/source-password>/g" /etc/icecast2/icecast.xml
sed -i "s/<relay-password>.*<\/relay-password>/<relay-password>${ICECAST_PASSWORD}<\/relay-password>/g" /etc/icecast2/icecast.xml
sed -i "s/<admin-password>.*<\/admin-password>/<admin-password>${ICECAST_PASSWORD}<\/admin-password>/g" /etc/icecast2/icecast.xml
# Generate or load symmetric encryption key for DB credentials
if [ -n "$ENCRYPTION_KEY" ]; then
echo "Using user-provided Encryption Key."
echo "$ENCRYPTION_KEY" > /data/.encryption_key
elif [ ! -f /data/.encryption_key ]; then
echo "Generating secure Encryption Key..."
# Generate a Fernet-compatible URL-safe base64 encoded 32-byte key
python3 -c "import base64; import os; print(base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8'))" > /data/.encryption_key
fi
export ENCRYPTION_KEY=$(cat /data/.encryption_key)
# Fix permissions on /data volume so the liquidsoap user can read/write the SQLite database
chown -R liquidsoap:liquidsoap /data
# Execute the main process (supervisord)
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
+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: './',
})
+1
View File
@@ -0,0 +1 @@
kasbergen:$2y$05$BIpUTNbICljGVS.UZfWFHe03n/t2YkHoFb6ON06xT5H68sxgltOr6
+50
View File
@@ -0,0 +1,50 @@
<icecast>
<location>Earth</location>
<admin>icemaster@localhost</admin>
<limits>
<clients>100</clients>
<sources>2</sources>
<queue-size>524288</queue-size>
<client-timeout>30</client-timeout>
<header-timeout>15</header-timeout>
<source-timeout>10</source-timeout>
<burst-on-connect>1</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>hackme</source-password>
<relay-password>hackme</relay-password>
<admin-user>admin</admin-user>
<admin-password>hackme</admin-password>
</authentication>
<hostname>localhost</hostname>
<listen-socket>
<port>8000</port>
</listen-socket>
<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
<header name="Access-Control-Allow-Headers" value="Origin, Accept, X-Requested-With, Content-Type" />
<header name="Access-Control-Allow-Methods" value="GET, OPTIONS, HEAD" />
</http-headers>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>/var/log/icecast2</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
<pidfile>/var/run/icecast2/icecast.pid</pidfile>
<alias source="/" destination="/status.xsl"/>
</paths>
<logging>
<accesslog>access.log</accesslog>
<errorlog>error.log</errorlog>
<loglevel>3</loglevel> <!-- 4 Debug, 3 Info, 2 Warn, 1 Error -->
<logsize>10000</logsize>
</logging>
</icecast>
+63
View File
@@ -0,0 +1,63 @@
# Logging settings
settings.log.file.path := "/tmp/radio.log"
settings.log.stdout := true
settings.log.level := 2
# Enable Telnet server for admin controls (like skipping)
settings.server.telnet := true
settings.server.telnet.bind_addr := "0.0.0.0"
settings.server.telnet.port := 1234
# Fetch track dynamically from our Python API
def get_next() =
# Liquidsoap http.get returns the body as a string directly
response = http.get("http://127.0.0.1:8080/next")
url = string.trim(response)
if url == "" then
print("WARNING: /next returned empty. Adding silence fallback.")
request.create("annotate:duration=5.0:silence")
else
print("Next track queued: #{url}")
request.create(url)
end
end
# Define the source
radio = request.dynamic(id="navifm_queue", get_next)
# Register a manual telnet command to skip the track since request.dynamic doesn't expose one by default
server.register("navifm_queue.skip", fun(_) -> begin source.skip(radio); "Done" end)
# Notify backend precisely when a new track starts
def on_new_track(meta) =
let title = meta["title"]
let artist = meta["artist"]
let url = "http://127.0.0.1:8080/api/internal/track_changed?title=#{url.encode(title)}&artist=#{url.encode(artist)}"
ignore(http.get(url))
end
radio.on_track(on_new_track)
# Precise static amplification based on LUFS (overridden by liq_amplify metadata)
radio = amplify(1., radio)
# Smooth crossfade between tracks
radio = crossfade(smart=true, duration=4.0, fade_out=2.0, fade_in=2.0, radio)
# Ensure the stream doesn't crash if the source is unavailable (apply right before output)
radio = mksafe(radio)
# Retrieve icecast password from environment variable
icecast_pwd = environment.get(default="hackme", "ICECAST_PASSWORD")
# Output stream to Icecast
output.icecast(
%mp3(bitrate=192),
host="127.0.0.1",
port=8000,
password=icecast_pwd,
mount="/stream",
name="Navi.FM",
description="Personal Smart Radio",
genre="Various",
radio
)
+5
View File
@@ -0,0 +1,5 @@
spawn telnet 127.0.0.1 1234
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Connection closed by foreign host.
+47
View File
@@ -0,0 +1,47 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0777
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[program:icecast]
command=icecast2 -c /etc/icecast2/icecast.xml
user=liquidsoap
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:liquidsoap]
command=liquidsoap /app/liquidsoap/radio.liq
user=liquidsoap
autostart=false
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
# Give Icecast a second to start before liquidsoap connects
startsecs=3
[program:fastapi]
command=/opt/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8080
user=liquidsoap
directory=/app/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+15
View File
@@ -0,0 +1,15 @@
set("server.telnet", true)
set("server.telnet.bind_addr", "0.0.0.0")
set("server.telnet.port", 1234)
def get_next() =
request.create("annotate:duration=5.0:silence")
end
radio = request.dynamic(id="navifm_queue", get_next)
# Try registering a telnet command to skip
server.register("navifm_queue.skip", fun(_) -> begin source.skip(radio); "Done" end)
radio = mksafe(radio)
output.dummy(radio)
+9
View File
@@ -0,0 +1,9 @@
import socket
with socket.create_connection(("127.0.0.1", 1234), timeout=1) as sock:
sock.sendall(b"help\r\nquit\r\n")
response = b""
while True:
chunk = sock.recv(4096)
if not chunk: break
response += chunk
print(response.decode())
+3
View File
@@ -0,0 +1,3 @@
class MockNavidrome:
pass
# Just to check if there is a way I can test Navidrome API locally