Backend serves a staged APK so co-users self-serve the latest build: - GET /api/apk streams APK_PATH (default /data/novasonic.apk) with the android package content-type + attachment disposition; 404 if absent. - GET /api/apk/info reports availability, size, mtime, and an optional version from /data/novasonic.version.txt. - Settings gets a "🤖 Android App" section that appears only when an APK is present and shows version/size/date. Maintainer updates the APK with: docker cp app-release.apk novasonic-backend:/data/novasonic.apk Android source is already tracked; only build artifacts stay out of git. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
177 lines
8.5 KiB
JavaScript
177 lines
8.5 KiB
JavaScript
import { S } from '../state.js';
|
||
import { esc, toast } from '../utils.js';
|
||
import { api, fetchRomajiIndex, invalidateCoverCache, getRomajiOverrides, setRomajiOverride, removeRomajiOverride } from '../api.js';
|
||
import { THEMES, applyTheme, getCurrentThemeId } from '../themes.js';
|
||
// Circular — safe (only used inside function bodies)
|
||
import { refreshDisplayMode, updateNpColorMode } from '../player/player.js';
|
||
import { startArtistSortFix } from './downloads.js';
|
||
|
||
// ─── SIDEBAR ───
|
||
export function openSidebar() {
|
||
document.getElementById('sidebar').classList.add('mobile-open');
|
||
document.getElementById('mobile-overlay').classList.add('active');
|
||
}
|
||
export function closeSidebar() {
|
||
document.getElementById('sidebar').classList.remove('mobile-open');
|
||
document.getElementById('mobile-overlay').classList.remove('active');
|
||
}
|
||
export function switchNavMode(mode) {
|
||
const lib = document.getElementById('bnav-library');
|
||
const disc = document.getElementById('bnav-discover');
|
||
if(!lib || !disc) return;
|
||
lib.style.display = mode==='library' ? 'flex' : 'none';
|
||
disc.style.display = mode==='discover' ? 'flex' : 'none';
|
||
document.getElementById('bnav-tab-library')?.classList.toggle('active', mode==='library');
|
||
document.getElementById('bnav-tab-discover')?.classList.toggle('active', mode==='discover');
|
||
}
|
||
|
||
// ─── MOBILE SEARCH (legacy stubs — header search is now always visible) ───
|
||
export function toggleMobileSearch() {
|
||
document.getElementById('header-search-input')?.focus();
|
||
}
|
||
export function closeMobileSearch() {
|
||
import('../views/views.js').then(m => m.closeHeaderSearch());
|
||
}
|
||
|
||
// ─── SETUP ───
|
||
export function switchSetupTab(t) {
|
||
document.querySelectorAll('.setup-tab').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
|
||
document.querySelectorAll('.setup-pane').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
|
||
}
|
||
|
||
// ─── SETTINGS ───
|
||
export function openSettings() {
|
||
import('../views/abs.js').then(m => m.absOpenSettings());
|
||
document.getElementById('s-url').value = S.cfg.url || '';
|
||
document.getElementById('s-user').value = S.cfg.user || '';
|
||
document.getElementById('s-pass').value = '';
|
||
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
|
||
document.getElementById('s-backend').value = S.cfg.backendUrl || '';
|
||
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
|
||
document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji);
|
||
document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg);
|
||
document.getElementById('s-scrobble-mode').value = S.cfg.scrobbleMode || '15s';
|
||
_renderThemePicker();
|
||
document.getElementById('settings-modal').classList.add('open');
|
||
checkBackend();
|
||
_loadApkInfo();
|
||
}
|
||
|
||
// Show the "download the Android APK" button only if the backend actually has an APK staged.
|
||
async function _loadApkInfo() {
|
||
const section = document.getElementById('s-apk-section');
|
||
const btn = document.getElementById('s-apk-btn');
|
||
const meta = document.getElementById('s-apk-meta');
|
||
if (!section || !btn) return;
|
||
section.style.display = 'none';
|
||
const base = S.cfg.backendUrl;
|
||
if (!base) return;
|
||
try {
|
||
const info = await fetch(`${base}/api/apk/info`).then(r => r.json());
|
||
if (!info?.available) return;
|
||
btn.href = `${base}/api/apk`;
|
||
const mb = info.size ? (info.size / 1048576).toFixed(1) + ' MB' : '';
|
||
const date = info.updatedAt ? new Date(info.updatedAt).toLocaleDateString('de-DE') : '';
|
||
if (meta) meta.textContent = [info.version && `v${info.version}`, mb, date && `Stand ${date}`].filter(Boolean).join(' · ');
|
||
section.style.display = '';
|
||
} catch {}
|
||
}
|
||
|
||
function _renderThemePicker() {
|
||
const el = document.getElementById('theme-picker');
|
||
if (!el) return;
|
||
const current = getCurrentThemeId();
|
||
el.innerHTML = THEMES.map(t => `
|
||
<div class="theme-swatch${t.id === current ? ' active' : ''}" data-id="${t.id}" onclick="_pickTheme('${t.id}')" style="--sw-bg:${t.bg};--sw-accent:${t.accent}">
|
||
<div class="theme-swatch-dot"></div>
|
||
<span>${esc(t.name)}</span>
|
||
</div>`).join('');
|
||
}
|
||
|
||
export function _pickTheme(id) {
|
||
applyTheme(id);
|
||
_renderThemePicker();
|
||
}
|
||
|
||
function _renderRomajiOverrides() {
|
||
const list = document.getElementById('romaji-overrides-list');
|
||
if (!list) return;
|
||
const entries = Object.entries(getRomajiOverrides());
|
||
if (!entries.length) {
|
||
list.innerHTML = '<div style="color:var(--text2);font-size:13px;padding:4px 0">Keine Korrekturen vorhanden</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = entries.map(([k, v]) =>
|
||
`<div style="display:flex;align-items:center;gap:6px;padding:7px 10px;background:var(--bg2);border-radius:8px">
|
||
<span style="flex:1;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(k)}</span>
|
||
<span style="color:var(--text2);flex-shrink:0">→</span>
|
||
<span style="flex:1.2;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(v)}</span>
|
||
<button onclick="removeRomajiOverrideUI('${esc(k)}')" style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:18px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
export async function addRomajiOverrideUI() {
|
||
const k = (document.getElementById('ro-kanji')?.value || '').trim();
|
||
const v = (document.getElementById('ro-romaji')?.value || '').trim();
|
||
if (!k || !v) { toast('⚠ Kanji und Romaji eingeben'); return; }
|
||
await setRomajiOverride(k, v);
|
||
document.getElementById('ro-kanji').value = '';
|
||
document.getElementById('ro-romaji').value = '';
|
||
_renderRomajiOverrides();
|
||
try { await startArtistSortFix(k, v); } catch (e) { toast('⚠ Fix-Artist-Sort: ' + e.message); }
|
||
}
|
||
|
||
export async function removeRomajiOverrideUI(kanji) {
|
||
await removeRomajiOverride(kanji);
|
||
_renderRomajiOverrides();
|
||
}
|
||
export function openRomajiOverridesModal() {
|
||
_renderRomajiOverrides();
|
||
document.getElementById('romaji-overrides-modal').classList.add('open');
|
||
}
|
||
export function closeRomajiOverridesModal() {
|
||
document.getElementById('romaji-overrides-modal').classList.remove('open');
|
||
}
|
||
|
||
export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); }
|
||
export async function confirmDisconnect() {
|
||
const { showConfirm } = await import('./modals.js');
|
||
const ok = await showConfirm('Abmelden', 'Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.', 'Abmelden');
|
||
if (ok) { closeSettings(); window.disconnect(); }
|
||
}
|
||
export function saveSettings() {
|
||
const pass = document.getElementById('s-pass').value;
|
||
S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,'');
|
||
S.cfg.user = document.getElementById('s-user').value.trim();
|
||
const credChanged = S.cfg.url !== document.getElementById('s-url').value.trim().replace(/\/$/,'') || !!pass;
|
||
if(pass) S.cfg.pass = pass;
|
||
if(credChanged) invalidateCoverCache();
|
||
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
|
||
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,'');
|
||
S.cfg.showPlayCount = document.getElementById('s-show-playcount').classList.contains('on');
|
||
const romajiChanged = S.cfg.showRomaji !== document.getElementById('s-show-romaji').classList.contains('on');
|
||
S.cfg.showRomaji = document.getElementById('s-show-romaji').classList.contains('on');
|
||
S.cfg.coverColorBg = document.getElementById('s-cover-color-bg').classList.contains('on');
|
||
S.cfg.scrobbleMode = document.getElementById('s-scrobble-mode').value || '15s';
|
||
updateNpColorMode();
|
||
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
|
||
closeSettings(); toast('✅ Gespeichert');
|
||
if (romajiChanged) {
|
||
fetchRomajiIndex().then(() => refreshDisplayMode());
|
||
}
|
||
}
|
||
export async function checkBackend() {
|
||
const dot = document.getElementById('backend-status-dot');
|
||
const txt = document.getElementById('backend-status-text');
|
||
const url = document.getElementById('s-backend').value.trim() || S.cfg.backendUrl;
|
||
if(!url) { dot.className='status-dot'; txt.textContent='Keine URL konfiguriert'; return; }
|
||
dot.className='status-dot'; txt.textContent='Verbinde…';
|
||
try {
|
||
const r = await fetch(`${url}/api/health`, {signal: AbortSignal.timeout(4000)});
|
||
const d = await r.json();
|
||
dot.className='status-dot ok';
|
||
txt.textContent = `Verbunden · yt-dlp ${d.ytdlpVersion||'OK'} · ${d.musicPath||''}`;
|
||
} catch(e) { dot.className='status-dot err'; txt.textContent='Nicht erreichbar: '+e.message; }
|
||
}
|