Initial commit - Navi.FM v3
This commit is contained in:
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
Generated
+12
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -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
@@ -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"]
|
||||||
Vendored
BIN
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.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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 you’ll 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")
|
||||||
@@ -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()
|
||||||
@@ -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."}
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
requests
|
||||||
|
httpx
|
||||||
|
librosa
|
||||||
|
soundfile
|
||||||
|
SQLAlchemy
|
||||||
|
jinja2
|
||||||
|
python-dotenv
|
||||||
|
pydantic
|
||||||
|
pyloudnorm
|
||||||
|
bcrypt
|
||||||
|
cryptography
|
||||||
+160
@@ -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()
|
||||||
|
|
||||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1130
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.185.1",
|
||||||
|
"vue": "^3.5.39"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,241 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import AudioPlayer from './components/AudioPlayer.vue'
|
||||||
|
import AdminPanel from './components/AdminPanel.vue'
|
||||||
|
import SetupWizard from './components/SetupWizard.vue'
|
||||||
|
import { settingsState, fetchSettings, THEME_PALETTE } from './store/settings'
|
||||||
|
import { onMounted, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
const currentView = ref('loading') // 'loading', 'setup', 'player', 'admin'
|
||||||
|
const authToken = ref(localStorage.getItem('adminToken') || null)
|
||||||
|
|
||||||
|
const checkSetupStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/status')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.is_setup) {
|
||||||
|
currentView.value = 'setup'
|
||||||
|
} else {
|
||||||
|
currentView.value = 'player'
|
||||||
|
fetchSettings()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentView.value = 'player'
|
||||||
|
fetchSettings()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check setup status', err)
|
||||||
|
currentView.value = 'player'
|
||||||
|
fetchSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkSetupStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
const themeStyles = computed(() => {
|
||||||
|
const palette = THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple
|
||||||
|
return {
|
||||||
|
'--theme-glow': palette.glow,
|
||||||
|
'--theme-accent': palette.accent,
|
||||||
|
'--theme-dark': palette.dark
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAdmin = () => {
|
||||||
|
currentView.value = currentView.value === 'player' ? 'admin' : 'player'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => settingsState.appTitle, (newTitle) => {
|
||||||
|
if (newTitle) {
|
||||||
|
document.title = newTitle
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => [settingsState.themeColor, settingsState.faviconSvgOverlay, settingsState.faviconSvgColor], () => {
|
||||||
|
const cacheBust = Date.now()
|
||||||
|
let link = document.querySelector("link[rel~='icon']")
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link')
|
||||||
|
link.rel = 'icon'
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
link.type = 'image/svg+xml'
|
||||||
|
link.href = `/favicon.svg?v=${cacheBust}`
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const handleLogin = (token) => {
|
||||||
|
if (token) {
|
||||||
|
authToken.value = token
|
||||||
|
localStorage.setItem('adminToken', token)
|
||||||
|
} else {
|
||||||
|
authToken.value = null
|
||||||
|
localStorage.removeItem('adminToken')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="app-container" :style="themeStyles">
|
||||||
|
<!-- Admin Toggle Button -->
|
||||||
|
<button v-if="currentView === 'player'" @click="toggleAdmin" class="admin-toggle-btn" aria-label="Open Admin Panel">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.73 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.49-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-show="currentView === 'player'" class="view-layer">
|
||||||
|
<AudioPlayer />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-show="currentView === 'admin'" class="view-layer">
|
||||||
|
<AdminPanel
|
||||||
|
v-if="currentView === 'admin' && currentView !== 'setup'"
|
||||||
|
:auth-token="authToken"
|
||||||
|
@login="handleLogin"
|
||||||
|
@close="toggleAdmin"
|
||||||
|
class="admin-flyout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<div v-if="currentView === 'loading'" class="loading-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SetupWizard v-else-if="currentView === 'setup'" @complete="currentView = 'player'; fetchSettings()" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--glass-bg: rgba(15, 7, 30, 0.45);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-border-hover: rgba(139, 92, 246, 0.3);
|
||||||
|
--glass-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
--theme-glow: #8b5cf6;
|
||||||
|
--theme-accent: #c4b5fd;
|
||||||
|
--theme-dark: #4c1d95;
|
||||||
|
|
||||||
|
--text-primary: #f8fafc;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
|
||||||
|
--transition-smooth: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
--transition-bounce: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #06030c;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 50;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toggle-btn svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-toggle-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-screen {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: radial-gradient(circle at center, #1e1b4b, #000);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-screen .spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid rgba(139, 92, 246, 0.2);
|
||||||
|
border-top-color: #8b5cf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,972 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import ThreeOrb from './ThreeOrb.vue'
|
||||||
|
import { playerState, togglePlay, toggleMute, setVolume, startPolling, stopPolling } from '../store/player'
|
||||||
|
import { settingsState } from '../store/settings'
|
||||||
|
|
||||||
|
const showInfoModal = ref(false)
|
||||||
|
const showHistory = ref(false)
|
||||||
|
|
||||||
|
const formatTimeAgo = (timestamp) => {
|
||||||
|
if (!timestamp) return 'Never'
|
||||||
|
const serverNow = Math.floor(Date.now() / 1000) - settingsState.serverTimeOffset
|
||||||
|
let seconds = serverNow - timestamp
|
||||||
|
if (seconds < 0) seconds = 0 // Prevent negative times if slight jitter occurs
|
||||||
|
if (seconds < 60) return `${seconds}s ago`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
return `${Math.floor(minutes / 60)}h ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="audio-player-view">
|
||||||
|
<!-- Background glowing ambient blobs -->
|
||||||
|
<div class="glow-bg">
|
||||||
|
<div class="blob blob-purple"></div>
|
||||||
|
<div class="blob blob-indigo"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-container">
|
||||||
|
<!-- Main glassmorphic player card -->
|
||||||
|
<div class="player-card" :class="{ 'card-playing': playerState.isPlaying }">
|
||||||
|
|
||||||
|
<!-- Dynamic Island Header -->
|
||||||
|
<div class="dynamic-island">
|
||||||
|
<button @click="showHistory = !showHistory" class="island-btn" title="Recently Played" aria-label="Recently Played">
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<polyline points="12 6 12 12 16 14"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="island-divider"></div>
|
||||||
|
|
||||||
|
<div class="island-center" :class="{ 'live-active': playerState.isStreamOnline }">
|
||||||
|
<span class="live-dot" :class="{ 'dot-pulse': playerState.isStreamOnline }"></span>
|
||||||
|
<span class="island-text">{{ playerState.isStreamOnline ? 'LIVE' : 'OFFLINE' }}</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="island-divider"></div>
|
||||||
|
|
||||||
|
<button @click="showInfoModal = true" class="island-btn" aria-label="About Navi.FM">?</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History Pull-out -->
|
||||||
|
<div class="history-pullout" :class="{ 'history-open': showHistory }">
|
||||||
|
<div class="history-pullout-header">
|
||||||
|
<h4>Recently Played</h4>
|
||||||
|
<button @click="showHistory = false" class="close-history">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="history-list" v-if="settingsState.history && settingsState.history.length > 0">
|
||||||
|
<div v-for="t in settingsState.history" :key="t.id" class="history-item">
|
||||||
|
<div class="history-info">
|
||||||
|
<span class="history-title">{{ t.title }}</span>
|
||||||
|
<span class="history-artist">{{ t.artist }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="history-time">{{ formatTimeAgo(t.last_played) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-history">
|
||||||
|
No history available yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Station Logo / 3D Orb -->
|
||||||
|
<div class="center-content-wrapper">
|
||||||
|
<div class="logo-section">
|
||||||
|
<div class="logo-ring" :class="{ 'ring-pulse': playerState.isPlaying }">
|
||||||
|
<ThreeOrb
|
||||||
|
:isPlaying="playerState.isPlaying"
|
||||||
|
:isLoading="playerState.isLoading"
|
||||||
|
:bpm="playerState.bpm"
|
||||||
|
:intensity="playerState.intensity"
|
||||||
|
:brightness="playerState.brightness"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Station Info -->
|
||||||
|
<div class="station-info">
|
||||||
|
<h2 class="station-title">{{ settingsState.appTitle }}</h2>
|
||||||
|
<p class="station-tagline">{{ settingsState.appTagline }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio Visualizer Waves -->
|
||||||
|
<div class="visualizer">
|
||||||
|
<div
|
||||||
|
v-for="n in 9"
|
||||||
|
:key="n"
|
||||||
|
class="vis-bar"
|
||||||
|
:class="{ 'vis-active': playerState.isPlaying, 'vis-buffering': playerState.isLoading }"
|
||||||
|
:style="{ animationDelay: `${n * 0.12}s` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Display Messages (Loading / Errors) -->
|
||||||
|
<div class="status-box">
|
||||||
|
<transition name="fade" mode="out-in">
|
||||||
|
<p v-if="playerState.error" class="status-text error-text">{{ playerState.error }}</p>
|
||||||
|
<p v-else-if="playerState.isLoading" class="status-text loading-text">
|
||||||
|
Connecting to stream<span class="loading-dots">...</span>
|
||||||
|
</p>
|
||||||
|
<p v-else-if="playerState.isPlaying" class="status-text active-text">
|
||||||
|
<span v-if="playerState.currentTrack">{{ playerState.currentTrack.artist }} - {{ playerState.currentTrack.title }}</span>
|
||||||
|
<span v-else>Streaming Live Audio</span>
|
||||||
|
</p>
|
||||||
|
<p v-else class="status-text idle-text">Ready to Tune In</p>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Central Controller -->
|
||||||
|
<div class="control-center">
|
||||||
|
<button
|
||||||
|
@click="togglePlay"
|
||||||
|
class="play-button"
|
||||||
|
:class="{ 'play-btn-active': playerState.isPlaying, 'play-btn-loading': playerState.isLoading }"
|
||||||
|
:aria-label="playerState.isPlaying ? 'Pause stream' : 'Play stream'"
|
||||||
|
:disabled="playerState.isLoading"
|
||||||
|
>
|
||||||
|
<!-- Play Icon -->
|
||||||
|
<svg v-if="!playerState.isPlaying && !playerState.isLoading" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="play-icon">
|
||||||
|
<path d="M8 5.14v14c0 .86.94 1.39 1.66.9l10-7c.61-.43.61-1.37 0-1.8l-10-7A1.07 1.07 0 0 0 8 5.14z"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Pause Icon -->
|
||||||
|
<svg v-else-if="playerState.isPlaying && !playerState.isLoading" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="pause-icon">
|
||||||
|
<path d="M6 19h4c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1H6c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1zm8-13v12c0 .55.45 1 1 1h4c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1h-4c-.55 0-1 .45-1 1z"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Spinner Ring for Loading State -->
|
||||||
|
<div v-else class="loading-spinner"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume Controller Section -->
|
||||||
|
<div class="volume-container">
|
||||||
|
<button @click="toggleMute" class="mute-button" :aria-label="playerState.isMuted ? 'Unmute' : 'Mute'">
|
||||||
|
<!-- Muted / Volume Off Icon -->
|
||||||
|
<svg v-if="playerState.isMuted || playerState.volume === 0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="vol-icon">
|
||||||
|
<path d="M3.63 3.63a.996.996 0 0 0 0 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-3.79l4.3 4.3a6.83 6.83 0 0 0 1.95-1.28c.38-.38.37-1 .01-1.37a.996.996 0 0 0-1.41 0c-.35.35-.74.65-1.16.89l-2.09-2.09c.65-.63 1.15-1.4 1.48-2.27c.18-.49-.12-1.02-.64-1.02c-.39 0-.74.24-.87.6c-.22.6-.57 1.13-1.02 1.58l-2.31-2.3V4c0-.89-1.08-1.34-1.71-.71L9.7 6.59l-4.66-4.66a.996.996 0 0 0-1.41 0zM12 4v7.29l-2.37-2.37L12 6.59V4zm6.5 8c0-2.33-1.02-4.42-2.65-5.85c-.39-.34-.99-.3-.1.32a.996.996 0 0 0 0 1.41c1.13.99 1.85 2.43 1.85 4.12c0 1.15-.36 2.22-.98 3.1l1.43 1.43c.85-1.37 1.35-2.98 1.35-4.81z"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Volume Low Icon -->
|
||||||
|
<svg v-else-if="playerState.volume < 0.4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="vol-icon">
|
||||||
|
<path d="M14 8.83v6.34c1.17-.83 2-2.2 2-3.75s-.83-2.92-2-3.75zM7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V4.71c0-.89-1.08-1.34-1.71-.71L7 9z"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Volume High Icon -->
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="vol-icon">
|
||||||
|
<path d="M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V4.71c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.61.85C17.06 6.54 19 9.04 19 12s-1.94 5.46-4.39 6.5a.994.994 0 0 0-.61.85v.2c0 .67.73 1.07 1.3.73C18.66 18.77 21 15.66 21 12s-2.34-6.77-5.7-8.28c-.57-.26-1.3.15-1.3.73z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="slider-wrapper">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
:value="playerState.volume"
|
||||||
|
@input="setVolume(Number($event.target.value))"
|
||||||
|
:disabled="playerState.isMuted"
|
||||||
|
class="volume-slider"
|
||||||
|
:style="{ '--val': `${playerState.isMuted ? 0 : playerState.volume * 100}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Glassmorphic Modal Overlay -->
|
||||||
|
<transition name="fade">
|
||||||
|
<div v-if="showInfoModal" class="modal-overlay" @click.self="showInfoModal = false">
|
||||||
|
<div class="modal-card">
|
||||||
|
<button @click="showInfoModal = false" class="close-modal-button" aria-label="Close modal">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="close-icon">
|
||||||
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div v-if="settingsState.infoModalText" class="modal-content">
|
||||||
|
<p style="white-space: pre-wrap; line-height: 1.6;">{{ settingsState.infoModalText }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" :class="{ 'no-content': !settingsState.infoModalText }">
|
||||||
|
Powered by Navi.FM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Main Player Layout */
|
||||||
|
.audio-player-view {
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
perspective: 1000px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card {
|
||||||
|
width: 360px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 30px 24px 70px;
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-playing {
|
||||||
|
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.8), 0 0 40px rgba(139, 92, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status System */
|
||||||
|
|
||||||
|
.live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-muted);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-active .live-dot {
|
||||||
|
background: #f43f5e;
|
||||||
|
box-shadow: 0 0 10px #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-pulse {
|
||||||
|
animation: pulse-red 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-active .live-text {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-ring {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(139, 92, 246, 0.03) 0%, rgba(6, 3, 12, 0.3) 100%);
|
||||||
|
border: 1px dashed rgba(139, 92, 246, 0.15);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ring-pulse {
|
||||||
|
border-color: rgba(139, 92, 246, 0.4);
|
||||||
|
box-shadow: 0 0 25px rgba(139, 92, 246, 0.12);
|
||||||
|
animation: ring-glow 3s infinite ease-in-out, spin-ring 25s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography and Station details */
|
||||||
|
.station-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: linear-gradient(135deg, var(--text-primary) 30%, var(--theme-accent) 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 20px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-tagline {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 6px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio Visualizer */
|
||||||
|
.visualizer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 40px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-bar {
|
||||||
|
width: 3px;
|
||||||
|
height: 6px;
|
||||||
|
background: linear-gradient(to top, var(--theme-dark), var(--theme-accent));
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-active {
|
||||||
|
animation: bounce-bar 1.2s infinite ease-in-out;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-buffering {
|
||||||
|
animation: pulse-bar 1s infinite ease-in-out;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Play/Pause Button */
|
||||||
|
.control-center {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, var(--theme-glow) 0%, var(--theme-dark) 100%);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 8px 24px rgba(139, 92, 246, 0.4), inset 0 2px 4px rgba(255, 255, 255, 0.2);
|
||||||
|
transition: var(--transition-bounce);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button:hover:not(:disabled) {
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow: 0 12px 30px rgba(139, 92, 246, 0.6), inset 0 2px 4px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button:active:not(:disabled) {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button:disabled {
|
||||||
|
opacity: 0.85;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-icon, .pause-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-button {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-bounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-button:hover {
|
||||||
|
background: rgba(139, 92, 246, 0.2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: rgba(139, 92, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner for loading state */
|
||||||
|
.loading-spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top: 3px solid var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume Slider UI */
|
||||||
|
.volume-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mute-button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vol-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(to right, var(--theme-glow) 0%, var(--theme-glow) var(--val), rgba(255, 255, 255, 0.1) var(--val), rgba(255, 255, 255, 0.1) 100%);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom thumb styling */
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-primary);
|
||||||
|
border: 2px solid var(--theme-glow);
|
||||||
|
box-shadow: 0 0 10px rgba(139, 92, 246, 0.5);
|
||||||
|
transition: var(--transition-bounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:hover::-webkit-slider-thumb:not(:disabled) {
|
||||||
|
transform: scale(1.25);
|
||||||
|
box-shadow: 0 0 14px rgba(139, 92, 246, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Box */
|
||||||
|
.status-box {
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle-text { color: var(--text-muted); }
|
||||||
|
.active-text { color: var(--theme-accent); font-family: 'JetBrains Mono', monospace; font-size: 11px;}
|
||||||
|
.loading-text { color: var(--text-secondary); }
|
||||||
|
.error-text { color: #f43f5e; font-weight: 600; }
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
display: inline-block;
|
||||||
|
animation: loading-dots 1.5s infinite;
|
||||||
|
width: 15px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations & Transitions */
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-red {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.4; transform: scale(0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ring-glow {
|
||||||
|
0%, 100% {
|
||||||
|
border-color: rgba(139, 92, 246, 0.1);
|
||||||
|
box-shadow: 0 0 15px rgba(139, 92, 246, 0.05);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: rgba(139, 92, 246, 0.45);
|
||||||
|
box-shadow: 0 0 30px rgba(139, 92, 246, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin-ring {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce-bar {
|
||||||
|
0%, 100% { height: 6px; }
|
||||||
|
50% { height: 32px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-bar {
|
||||||
|
0%, 100% { height: 6px; opacity: 0.3; }
|
||||||
|
50% { height: 16px; opacity: 0.8; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading-dots {
|
||||||
|
0% { content: ''; }
|
||||||
|
33% { content: '.'; }
|
||||||
|
66% { content: '..'; }
|
||||||
|
100% { content: '...'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(15, 7, 30, 0.7);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 40px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.3);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-island:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow: 0 15px 35px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
margin: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-divider-small {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
margin: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-active .island-text {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pullout {
|
||||||
|
position: absolute;
|
||||||
|
top: 4.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 260px;
|
||||||
|
background: rgba(10, 10, 10, 0.85);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 20;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pullout.history-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pullout-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-pullout-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-history {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-history:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.75rem;
|
||||||
|
}
|
||||||
|
.history-list::-webkit-scrollbar { width: 4px; }
|
||||||
|
.history-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-artist {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-time {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-history {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ambient glow blobs */
|
||||||
|
.glow-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.15;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-purple {
|
||||||
|
width: 450px;
|
||||||
|
height: 450px;
|
||||||
|
background-color: var(--theme-glow);
|
||||||
|
top: 15%;
|
||||||
|
left: 10%;
|
||||||
|
animation: float-blob-1 12s infinite alternate ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blob-indigo {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background-color: #3b0764;
|
||||||
|
bottom: 10%;
|
||||||
|
right: 10%;
|
||||||
|
animation: float-blob-2 15s infinite alternate ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-blob-1 {
|
||||||
|
0% { transform: translate(0, 0) scale(1); }
|
||||||
|
100% { transform: translate(50px, 40px) scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-blob-2 {
|
||||||
|
0% { transform: translate(0, 0) scale(1.1); }
|
||||||
|
100% { transform: translate(-60px, -30px) scale(0.9); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page transitions */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info Button */
|
||||||
|
.info-trigger-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-trigger-button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--theme-glow);
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
box-shadow: 0 0 10px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Overlay */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(6, 3, 12, 0.75);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Card */
|
||||||
|
.modal-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
animation: modal-slide 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal-button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Typography */
|
||||||
|
.modal-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section h3 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--theme-accent);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer.no-content {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modal-slide {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import viteLogo from '../assets/vite.svg'
|
||||||
|
import heroImg from '../assets/hero.png'
|
||||||
|
import vueLogo from '../assets/vue.svg'
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="center">
|
||||||
|
<div class="hero">
|
||||||
|
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||||
|
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||||
|
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Get started</h1>
|
||||||
|
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="counter" @click="count++">
|
||||||
|
Count is {{ count }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
|
||||||
|
<section id="next-steps">
|
||||||
|
<div id="docs">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#documentation-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>Your questions, answered</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vite.dev/" target="_blank">
|
||||||
|
<img class="logo" :src="viteLogo" alt="" />
|
||||||
|
Explore Vite
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuejs.org/" target="_blank">
|
||||||
|
<img class="button-icon" :src="vueLogo" alt="" />
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="social">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#social-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Connect with us</h2>
|
||||||
|
<p>Join the Vite community</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#github-icon"></use>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vite.dev/" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#discord-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/vite_js" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#x-icon"></use>
|
||||||
|
</svg>
|
||||||
|
X.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#bluesky-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
<section id="spacer"></section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
<template>
|
||||||
|
<div class="setup-container">
|
||||||
|
<div class="setup-card glass-panel">
|
||||||
|
<div class="logo-area">
|
||||||
|
<h1>NAVI.FM</h1>
|
||||||
|
<p class="subtitle" v-if="step === 1">Welcome! Let's get things set up.</p>
|
||||||
|
<p class="subtitle" v-if="step === 2">Connect your library.</p>
|
||||||
|
<p class="subtitle" v-if="step === 3">Initializing Engine...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Admin Setup -->
|
||||||
|
<form v-if="step === 1" @submit.prevent="submitAdmin" class="setup-form">
|
||||||
|
<p class="step-desc">First, create an admin account to manage your station.</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Admin Username</label>
|
||||||
|
<input v-model="adminForm.username" type="text" required placeholder="admin" autofocus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Admin Password</label>
|
||||||
|
<input v-model="adminForm.password" type="password" required placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Confirm Password</label>
|
||||||
|
<input v-model="adminForm.confirmPassword" type="password" required placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn" :disabled="isLoading">
|
||||||
|
{{ isLoading ? 'Saving...' : 'Next Step →' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 2: Navidrome Setup -->
|
||||||
|
<form v-if="step === 2" @submit.prevent="submitNavidrome" class="setup-form">
|
||||||
|
<p class="step-desc">Connect Navi.FM to your Navidrome server to start importing tracks.</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Navidrome URL</label>
|
||||||
|
<input v-model="navForm.url" type="url" required placeholder="https://navidrome.yourdomain.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Navidrome Username</label>
|
||||||
|
<input v-model="navForm.username" type="text" required placeholder="username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Navidrome Password</label>
|
||||||
|
<input v-model="navForm.password" type="password" required placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="error-msg">{{ error }}</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn" :disabled="isLoading">
|
||||||
|
{{ isLoading ? 'Connecting...' : 'Connect Library' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 3: Waiting for Analysis -->
|
||||||
|
<div v-if="step === 3" class="waiting-state">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p class="step-desc">Your station is syncing tracks from Navidrome and the engine is analyzing the audio profiles. This might take a few minutes.</p>
|
||||||
|
|
||||||
|
<div class="progress-info">
|
||||||
|
<span class="track-count">{{ analyzedTracks }} / 10</span>
|
||||||
|
<span class="track-label">minimum tracks analyzed</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['complete'])
|
||||||
|
|
||||||
|
const step = ref(1)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const adminForm = ref({ username: '', password: '', confirmPassword: '' })
|
||||||
|
const navForm = ref({ url: '', username: '', password: '' })
|
||||||
|
|
||||||
|
const analyzedTracks = ref(0)
|
||||||
|
let pollInterval = null
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/status')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
analyzedTracks.value = data.analyzed_tracks
|
||||||
|
|
||||||
|
if (data.is_setup) {
|
||||||
|
emit('complete')
|
||||||
|
} else if (data.admin_setup && data.navidrome_setup) {
|
||||||
|
step.value = 3
|
||||||
|
} else if (data.admin_setup) {
|
||||||
|
step.value = 2
|
||||||
|
} else {
|
||||||
|
step.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check setup status', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkStatus()
|
||||||
|
pollInterval = setInterval(checkStatus, 3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitAdmin = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (adminForm.value.password !== adminForm.value.confirmPassword) {
|
||||||
|
error.value = 'Passwords do not match'
|
||||||
|
isLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(adminForm.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
step.value = 2
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
error.value = data.detail || 'Failed to setup admin'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Network error'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitNavidrome = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/setup/navidrome', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(navForm.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
step.value = 3
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
error.value = data.detail || 'Failed to connect Navidrome'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Network error'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.setup-container {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: radial-gradient(circle at center, #1e1b4b, #000);
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-area {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-area h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #fff, #c4b5fd);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #a78bfa;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-desc {
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e5e7eb;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #8b5cf6;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #8b5cf6, #6d28d9);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid rgba(139, 92, 246, 0.2);
|
||||||
|
border-top-color: #8b5cf6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-count {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 20px rgba(139, 92, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-label {
|
||||||
|
color: #a78bfa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { settingsState, THEME_PALETTE } from '../store/settings'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
isPlaying: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
bpm: {
|
||||||
|
type: Number,
|
||||||
|
default: 120
|
||||||
|
},
|
||||||
|
intensity: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.6
|
||||||
|
},
|
||||||
|
brightness: {
|
||||||
|
type: Number,
|
||||||
|
default: 0.6
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const canvasRef = ref(null)
|
||||||
|
let renderer, scene, camera, mesh, material
|
||||||
|
let animationFrameId
|
||||||
|
let clock
|
||||||
|
|
||||||
|
const initThree = () => {
|
||||||
|
if (!canvasRef.value) return
|
||||||
|
|
||||||
|
// Scene setup
|
||||||
|
scene = new THREE.Scene()
|
||||||
|
|
||||||
|
// Camera setup
|
||||||
|
const width = canvasRef.value.clientWidth || 100
|
||||||
|
const height = canvasRef.value.clientHeight || 100
|
||||||
|
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 100)
|
||||||
|
camera.position.z = 6
|
||||||
|
|
||||||
|
// Renderer setup
|
||||||
|
renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas: canvasRef.value,
|
||||||
|
antialias: true,
|
||||||
|
alpha: true // Enables transparent background
|
||||||
|
})
|
||||||
|
renderer.setSize(width, height, false)
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||||
|
|
||||||
|
// Geometry
|
||||||
|
const geometry = new THREE.SphereGeometry(1.6, 64, 64)
|
||||||
|
|
||||||
|
// Custom Shader Material for glowing organic plasma effect
|
||||||
|
clock = new THREE.Clock()
|
||||||
|
|
||||||
|
material = new THREE.ShaderMaterial({
|
||||||
|
vertexShader: `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uNoiseStrength;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vPosition;
|
||||||
|
|
||||||
|
// Symmetrical sine-based organic noise for displacement
|
||||||
|
float getNoise(vec3 p) {
|
||||||
|
return sin(p.x * 2.5 + uTime * 1.5) *
|
||||||
|
cos(p.y * 2.5 + uTime * 1.2) *
|
||||||
|
sin(p.z * 2.5 + uTime * 1.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vNormal = normalize(normalMatrix * normal);
|
||||||
|
vPosition = position;
|
||||||
|
|
||||||
|
float displacement = getNoise(position) * uNoiseStrength;
|
||||||
|
vec3 newPosition = position + normal * displacement;
|
||||||
|
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform float uTime;
|
||||||
|
uniform float uBrightness;
|
||||||
|
uniform vec3 uColorGlow;
|
||||||
|
uniform vec3 uColorAccent;
|
||||||
|
uniform vec3 uColorDark;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vPosition;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Fresnel effect for glowing outline
|
||||||
|
vec3 n = normalize(vNormal);
|
||||||
|
float intensity = 1.0 - max(dot(n, vec3(0.0, 0.0, 1.0)), 0.0);
|
||||||
|
|
||||||
|
vec3 colorWhite = vec3(1.0, 1.0, 1.0);
|
||||||
|
|
||||||
|
// Base core gets brighter/whiter with track brightness
|
||||||
|
vec3 coreColor = mix(uColorGlow, colorWhite, uBrightness * 0.35);
|
||||||
|
|
||||||
|
// Shifting color gradient across position and time
|
||||||
|
float mixVal = sin(vPosition.x * 1.8 + uTime) * 0.5 + 0.5;
|
||||||
|
vec3 baseColor = mix(coreColor, uColorAccent, mixVal);
|
||||||
|
baseColor = mix(baseColor, uColorDark, sin(vPosition.y * 1.5 - uTime * 0.8) * (0.2 + uBrightness * 0.3));
|
||||||
|
|
||||||
|
// Glowing rim highlight scaled by brightness
|
||||||
|
float glowFactor = 1.2 + uBrightness * 2.2;
|
||||||
|
float glow = pow(intensity, 2.8) * glowFactor;
|
||||||
|
vec3 finalColor = baseColor + vec3(glow) * uColorGlow;
|
||||||
|
|
||||||
|
// Core highlight for glass-like reflection
|
||||||
|
float core = max(dot(n, vec3(0.0, 0.0, 1.0)), 0.0);
|
||||||
|
finalColor += vec3(pow(core, 5.0) * (0.35 + uBrightness * 0.65));
|
||||||
|
|
||||||
|
gl_FragColor = vec4(finalColor, 0.85);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uNoiseStrength: { value: 0.08 },
|
||||||
|
uBrightness: { value: 0.6 },
|
||||||
|
uColorGlow: { value: new THREE.Color((THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple).glow) },
|
||||||
|
uColorAccent: { value: new THREE.Color((THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple).accent) },
|
||||||
|
uColorDark: { value: new THREE.Color((THEME_PALETTE[settingsState.themeColor] || THEME_PALETTE.purple).dark) }
|
||||||
|
},
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: true,
|
||||||
|
depthTest: true
|
||||||
|
})
|
||||||
|
|
||||||
|
mesh = new THREE.Mesh(geometry, material)
|
||||||
|
scene.add(mesh)
|
||||||
|
|
||||||
|
// Adjust noise strength initially
|
||||||
|
updateNoiseStrength()
|
||||||
|
|
||||||
|
// Start loop
|
||||||
|
animate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateNoiseStrength = () => {
|
||||||
|
if (!material) return
|
||||||
|
|
||||||
|
let targetStrength = 0.06 // Idle
|
||||||
|
if (props.isLoading) {
|
||||||
|
targetStrength = 0.15 // Buffering pulse
|
||||||
|
} else if (props.isPlaying) {
|
||||||
|
targetStrength = 0.28 // Playing organic morphing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition noise strength smoothly
|
||||||
|
THREE.MathUtils.lerp(material.uniforms.uNoiseStrength.value, targetStrength, 0.1)
|
||||||
|
material.uniforms.uNoiseStrength.value = targetStrength
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch props to update shader parameters
|
||||||
|
watch(() => [props.isPlaying, props.isLoading, props.intensity, props.brightness], () => {
|
||||||
|
if (material) {
|
||||||
|
let targetStrength = 0.06
|
||||||
|
if (props.isLoading) targetStrength = 0.15
|
||||||
|
else if (props.isPlaying) targetStrength = 0.06 + props.intensity * 0.24
|
||||||
|
|
||||||
|
THREE.MathUtils.lerp(material.uniforms.uNoiseStrength.value, targetStrength, 0.1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch theme color to dynamically update orb colors
|
||||||
|
watch(() => settingsState.themeColor, (newColor) => {
|
||||||
|
if (material) {
|
||||||
|
const palette = THEME_PALETTE[newColor] || THEME_PALETTE.purple
|
||||||
|
material.uniforms.uColorGlow.value.set(palette.glow)
|
||||||
|
material.uniforms.uColorAccent.value.set(palette.accent)
|
||||||
|
material.uniforms.uColorDark.value.set(palette.dark)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
animationFrameId = requestAnimationFrame(animate)
|
||||||
|
|
||||||
|
if (material) {
|
||||||
|
const elapsedTime = clock.getElapsedTime()
|
||||||
|
|
||||||
|
// Playback state controls animation speed multiplier
|
||||||
|
let speedMult = 0.5
|
||||||
|
if (props.isLoading) {
|
||||||
|
speedMult = 2.5
|
||||||
|
} else if (props.isPlaying) {
|
||||||
|
// Scale morphing speed based on track BPM
|
||||||
|
speedMult = 0.8 + (props.bpm / 120) * 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
material.uniforms.uTime.value = elapsedTime * speedMult
|
||||||
|
|
||||||
|
// Smoothly interpolate noise strength
|
||||||
|
let targetStrength = 0.06
|
||||||
|
if (props.isLoading) {
|
||||||
|
targetStrength = 0.16
|
||||||
|
} else if (props.isPlaying) {
|
||||||
|
// Scaled by track intensity
|
||||||
|
targetStrength = 0.06 + props.intensity * 0.22
|
||||||
|
}
|
||||||
|
|
||||||
|
material.uniforms.uNoiseStrength.value = THREE.MathUtils.lerp(
|
||||||
|
material.uniforms.uNoiseStrength.value,
|
||||||
|
targetStrength,
|
||||||
|
0.05
|
||||||
|
)
|
||||||
|
|
||||||
|
// Smoothly interpolate brightness uniform
|
||||||
|
let targetBrightness = 0.2
|
||||||
|
if (props.isLoading) {
|
||||||
|
targetBrightness = 0.6
|
||||||
|
} else if (props.isPlaying) {
|
||||||
|
targetBrightness = props.brightness
|
||||||
|
}
|
||||||
|
material.uniforms.uBrightness.value = THREE.MathUtils.lerp(
|
||||||
|
material.uniforms.uBrightness.value,
|
||||||
|
targetBrightness,
|
||||||
|
0.05
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mesh) {
|
||||||
|
const elapsedTime = clock.getElapsedTime()
|
||||||
|
|
||||||
|
// Slow rotational drift (spins faster for higher intensity/BPM)
|
||||||
|
let rotationSpeed = 0.004
|
||||||
|
if (props.isPlaying) {
|
||||||
|
rotationSpeed = 0.006 + props.intensity * 0.01
|
||||||
|
}
|
||||||
|
mesh.rotation.y += rotationSpeed
|
||||||
|
mesh.rotation.x += rotationSpeed * 0.5
|
||||||
|
|
||||||
|
// Thumping pulse matching BPM
|
||||||
|
let scaleVal = 1.0
|
||||||
|
if (props.isPlaying) {
|
||||||
|
const beatDuration = 60 / props.bpm
|
||||||
|
const beatProgress = (elapsedTime % beatDuration) / beatDuration
|
||||||
|
// Kick drum pulse curve (sharp punch, exponential decay)
|
||||||
|
const beatWave = Math.pow(Math.sin(beatProgress * Math.PI), 4)
|
||||||
|
scaleVal = 1.0 + beatWave * 0.08 * props.intensity
|
||||||
|
}
|
||||||
|
mesh.scale.set(scaleVal, scaleVal, scaleVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeObserver;
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!canvasRef.value || !renderer || !camera) return
|
||||||
|
const width = canvasRef.value.parentElement.clientWidth
|
||||||
|
const height = canvasRef.value.parentElement.clientHeight
|
||||||
|
|
||||||
|
if (width === 0 || height === 0) return
|
||||||
|
|
||||||
|
camera.aspect = width / height
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.setSize(width, height, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initThree()
|
||||||
|
|
||||||
|
if (canvasRef.value && canvasRef.value.parentElement) {
|
||||||
|
resizeObserver = new ResizeObserver(handleResize)
|
||||||
|
resizeObserver.observe(canvasRef.value.parentElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect()
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
|
||||||
|
if (renderer) {
|
||||||
|
renderer.dispose()
|
||||||
|
}
|
||||||
|
if (mesh) {
|
||||||
|
mesh.geometry.dispose()
|
||||||
|
mesh.material.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="orb-container">
|
||||||
|
<canvas ref="canvasRef" class="orb-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.orb-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import { settingsState } from './settings'
|
||||||
|
|
||||||
|
const streamUrl = `/stream`
|
||||||
|
|
||||||
|
export const playerState = reactive({
|
||||||
|
isPlaying: false,
|
||||||
|
isLoading: false,
|
||||||
|
isMuted: false,
|
||||||
|
volume: 0.8,
|
||||||
|
error: null,
|
||||||
|
isStreamOnline: false,
|
||||||
|
currentTrack: null,
|
||||||
|
bpm: 120,
|
||||||
|
intensity: 0.5,
|
||||||
|
brightness: 0.5
|
||||||
|
})
|
||||||
|
|
||||||
|
let audio = null
|
||||||
|
let pollInterval = null
|
||||||
|
|
||||||
|
// Fetch currently playing track metadata
|
||||||
|
export const fetchNowPlaying = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/now-playing')
|
||||||
|
if (response.ok) {
|
||||||
|
playerState.isStreamOnline = true
|
||||||
|
const data = await response.json()
|
||||||
|
playerState.currentTrack = data
|
||||||
|
|
||||||
|
if (data.listeners !== undefined) {
|
||||||
|
settingsState.listenerCount = data.listeners
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.bpm) playerState.bpm = data.bpm
|
||||||
|
|
||||||
|
if (data.energy !== undefined) {
|
||||||
|
playerState.intensity = Math.max(0.2, Math.min(1.0, data.energy))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.lufs !== undefined) {
|
||||||
|
const lufsVal = data.lufs
|
||||||
|
let b = (lufsVal + 24) / 19
|
||||||
|
playerState.brightness = Math.max(0.2, Math.min(1.0, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.history) {
|
||||||
|
settingsState.history = data.history
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.server_time) {
|
||||||
|
settingsState.serverTimeOffset = Math.floor(Date.now() / 1000) - data.server_time
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
playerState.isStreamOnline = false
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch now-playing data:', err)
|
||||||
|
playerState.isStreamOnline = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize and bind audio listeners
|
||||||
|
const initAudio = () => {
|
||||||
|
if (audio) return
|
||||||
|
|
||||||
|
audio = new Audio()
|
||||||
|
|
||||||
|
audio.addEventListener('playing', () => {
|
||||||
|
playerState.isLoading = false
|
||||||
|
playerState.isPlaying = true
|
||||||
|
playerState.error = null
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.addEventListener('waiting', () => {
|
||||||
|
playerState.isLoading = true
|
||||||
|
})
|
||||||
|
|
||||||
|
audio.addEventListener('error', (e) => {
|
||||||
|
console.error('Audio element error:', e)
|
||||||
|
const code = audio.error ? audio.error.code : 'unknown'
|
||||||
|
let msg = `Stream offline (code: ${code})`
|
||||||
|
if (code === 4) msg = `Stream offline or unsupported format.`
|
||||||
|
if (code === 2) msg = `Network error connecting to stream.`
|
||||||
|
playerState.error = msg
|
||||||
|
playerState.isLoading = false
|
||||||
|
playerState.isPlaying = false
|
||||||
|
cleanupAudio()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup audio resource
|
||||||
|
export const cleanupAudio = () => {
|
||||||
|
if (audio) {
|
||||||
|
try {
|
||||||
|
audio.pause()
|
||||||
|
audio.removeAttribute('src')
|
||||||
|
audio.load()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error during cleanup:', e)
|
||||||
|
}
|
||||||
|
audio = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Play / Pause
|
||||||
|
export const togglePlay = () => {
|
||||||
|
if (playerState.isPlaying) {
|
||||||
|
pauseStream()
|
||||||
|
} else {
|
||||||
|
playStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play stream (dynamically loading it to get fresh live feed)
|
||||||
|
export const playStream = async () => {
|
||||||
|
playerState.error = null
|
||||||
|
playerState.isLoading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
initAudio()
|
||||||
|
// Append timestamp to bypass browser caching of dead stream connections
|
||||||
|
audio.src = `${streamUrl}?cb=${Date.now()}`
|
||||||
|
audio.volume = playerState.isMuted ? 0 : playerState.volume
|
||||||
|
audio.load()
|
||||||
|
|
||||||
|
await audio.play()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Playback failed:', err)
|
||||||
|
playerState.error = `Playback blocked (${err.name || 'unknown error'})`
|
||||||
|
playerState.isLoading = false
|
||||||
|
playerState.isPlaying = false
|
||||||
|
cleanupAudio()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause stream (unloads stream to save bandwidth and keep live alignment)
|
||||||
|
export const pauseStream = () => {
|
||||||
|
playerState.isPlaying = false
|
||||||
|
playerState.isLoading = false
|
||||||
|
cleanupAudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Mute
|
||||||
|
export const toggleMute = () => {
|
||||||
|
playerState.isMuted = !playerState.isMuted
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = playerState.isMuted ? 0 : playerState.volume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Volume directly
|
||||||
|
export const setVolume = (newVal) => {
|
||||||
|
playerState.volume = newVal
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = playerState.isMuted ? 0 : newVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start Background Polling (Called exactly once in App.vue or AudioPlayer)
|
||||||
|
export const startPolling = () => {
|
||||||
|
fetchNowPlaying()
|
||||||
|
if (!pollInterval) {
|
||||||
|
pollInterval = setInterval(fetchNowPlaying, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stopPolling = () => {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export const settingsState = reactive({
|
||||||
|
themeColor: 'purple',
|
||||||
|
appTitle: 'NAVI.FM',
|
||||||
|
appTagline: 'The least intelligent radio software you’ll ever love.',
|
||||||
|
infoModalText: '',
|
||||||
|
artistSeparationEnabled: false,
|
||||||
|
artistSeparationCount: 5,
|
||||||
|
faviconSvgOverlay: '',
|
||||||
|
faviconSvgColor: 'white',
|
||||||
|
autoPilotSchedule: [],
|
||||||
|
history: [],
|
||||||
|
listenerCount: 0,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
serverTimeOffset: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// The 8 primary theme colors available for branding
|
||||||
|
export const THEME_PALETTE = {
|
||||||
|
purple: {
|
||||||
|
glow: '#8b5cf6',
|
||||||
|
accent: '#c4b5fd',
|
||||||
|
dark: '#4c1d95'
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
glow: '#3b82f6',
|
||||||
|
accent: '#93c5fd',
|
||||||
|
dark: '#1e3a8a'
|
||||||
|
},
|
||||||
|
cyan: {
|
||||||
|
glow: '#06b6d4',
|
||||||
|
accent: '#67e8f9',
|
||||||
|
dark: '#164e63'
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
glow: '#10b981',
|
||||||
|
accent: '#6ee7b7',
|
||||||
|
dark: '#064e3b'
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
glow: '#eab308',
|
||||||
|
accent: '#fde047',
|
||||||
|
dark: '#713f12'
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
glow: '#f97316',
|
||||||
|
accent: '#fdba74',
|
||||||
|
dark: '#7c2d12'
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
glow: '#ef4444',
|
||||||
|
accent: '#fca5a5',
|
||||||
|
dark: '#7f1d1d'
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
glow: '#ec4899',
|
||||||
|
accent: '#f9a8d4',
|
||||||
|
dark: '#831843'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchSettings = async () => {
|
||||||
|
settingsState.isLoading = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
settingsState.themeColor = data.theme_color || 'purple'
|
||||||
|
settingsState.appTitle = data.app_title || 'NAVI.FM'
|
||||||
|
settingsState.appTagline = data.app_tagline || ''
|
||||||
|
settingsState.infoModalText = data.info_modal_text || ''
|
||||||
|
settingsState.faviconSvgOverlay = data.favicon_svg_overlay || ''
|
||||||
|
settingsState.faviconSvgColor = data.favicon_svg_color || 'white'
|
||||||
|
settingsState.artistSeparationEnabled = !!data.artist_separation_enabled
|
||||||
|
settingsState.artistSeparationCount = data.artist_separation_count || 5
|
||||||
|
try {
|
||||||
|
settingsState.autoPilotSchedule = JSON.parse(data.auto_pilot_schedule || '[]')
|
||||||
|
} catch (e) {
|
||||||
|
settingsState.autoPilotSchedule = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch settings:', err)
|
||||||
|
} finally {
|
||||||
|
settingsState.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateSettings = async (token, newSettings) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Basic ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
theme_color: newSettings.themeColor,
|
||||||
|
app_title: newSettings.appTitle,
|
||||||
|
app_tagline: newSettings.appTagline,
|
||||||
|
info_modal_text: newSettings.infoModalText,
|
||||||
|
favicon_svg_overlay: newSettings.faviconSvgOverlay,
|
||||||
|
favicon_svg_color: newSettings.faviconSvgColor,
|
||||||
|
artist_separation_enabled: newSettings.artistSeparationEnabled,
|
||||||
|
artist_separation_count: newSettings.artistSeparationCount,
|
||||||
|
auto_pilot_schedule: JSON.stringify(newSettings.autoPilotSchedule || [])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// Immediately reflect changes in frontend store
|
||||||
|
settingsState.themeColor = newSettings.themeColor
|
||||||
|
settingsState.appTitle = newSettings.appTitle
|
||||||
|
settingsState.appTagline = newSettings.appTagline
|
||||||
|
settingsState.infoModalText = newSettings.infoModalText
|
||||||
|
settingsState.faviconSvgOverlay = newSettings.faviconSvgOverlay
|
||||||
|
settingsState.faviconSvgColor = newSettings.faviconSvgColor
|
||||||
|
settingsState.artistSeparationEnabled = newSettings.artistSeparationEnabled
|
||||||
|
settingsState.artistSeparationCount = newSettings.artistSeparationCount
|
||||||
|
settingsState.autoPilotSchedule = newSettings.autoPilotSchedule
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
return { success: false, status: res.status }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update settings:', err)
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchHistory = async (token) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/history', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${token}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
settingsState.history = data.history || []
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch history:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: './',
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
kasbergen:$2y$05$BIpUTNbICljGVS.UZfWFHe03n/t2YkHoFb6ON06xT5H68sxgltOr6
|
||||||
+50
@@ -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>
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
class MockNavidrome:
|
||||||
|
pass
|
||||||
|
# Just to check if there is a way I can test Navidrome API locally
|
||||||
Reference in New Issue
Block a user