From 36a0b38ecaee6fb5dbb5dfd3aa4d294e94054abc Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 5 Jun 2026 10:47:25 +0200 Subject: [PATCH] feat: server-side romaji overrides (shared list); preload ExoPlayer from SharedPreferences on cold start Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/com/novasonic/app/MusicService.kt | 13 ++++++ .../kotlin/com/novasonic/app/WebBridge.kt | 15 +++++++ backend/lib/config.js | 1 + backend/novasonic-server.js | 38 +++++++++++++++++ docker-compose.yml | 5 +++ src/api.js | 41 ++++++++++++++----- src/main.js | 3 +- src/player.js | 8 ++++ src/ui.js | 6 +-- 9 files changed, 116 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt index 3612a51..57ad155 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/MusicService.kt @@ -135,6 +135,19 @@ class MusicService : MediaSessionService() { .build() player.addListener(PlayerListener()) + + // Pre-warm: load the last played track immediately so ExoPlayer starts + // buffering while the WebView is still loading — reduces startup latency. + try { + val prefs = getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE) + val trackJson = prefs.getString("last_played_track", null) + if (!trackJson.isNullOrBlank()) { + val track = Track.fromJson(org.json.JSONObject(trackJson)) + player.setMediaItem(track.toMediaItem()) + player.prepare() + // No play() — wait for JS to decide whether to auto-play + } + } catch (_: Exception) {} } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession diff --git a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt index de0cefd..dbb0e93 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/WebBridge.kt @@ -134,6 +134,21 @@ class WebBridge(private val controllerProvider: () -> MediaController?) { @JavascriptInterface fun getState(): String = stateSnapshot + // ─── Last played (persisted for startup preload) ────────────────────────── + + // Called by JS whenever the current song changes — persists to SharedPreferences + // so MusicService can start buffering immediately on next launch before JS loads. + @JavascriptInterface + fun saveLastPlayed(trackJson: String) { + runOnMain { + try { + val ctx = MusicService.instance ?: return@runOnMain + ctx.getSharedPreferences("ns_prefs", android.content.Context.MODE_PRIVATE) + .edit().putString("last_played_track", trackJson).apply() + } catch (_: Exception) {} + } + } + // ─── Overlay state (for back-button routing) ────────────────────────────── // Written by WebView thread; read by UI thread — must be @Volatile. diff --git a/backend/lib/config.js b/backend/lib/config.js index c6c951c..4a35478 100644 --- a/backend/lib/config.js +++ b/backend/lib/config.js @@ -1,6 +1,7 @@ const CONFIG = { port: process.env.PORT || 3700, musicPath: process.env.MUSIC_PATH || '/music', + dataPath: process.env.DATA_PATH || '/data', ytdlpBin: process.env.YTDLP_BIN || 'yt-dlp', audioFmt: process.env.AUDIO_FMT || 'mp3', audioQuality: process.env.AUDIO_QUALITY || '0', diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 5c7fec9..0a41b80 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -188,6 +188,44 @@ app.delete('/api/romaji-index', (req, res) => { res.json({ ok: true }); }); +// ─── ROMAJI OVERRIDES (server-side, shared across all clients) ─────────────── +const _overridesFile = path.join(CONFIG.dataPath, 'romaji-overrides.json'); + +function _loadOverrides() { + try { return JSON.parse(fs.readFileSync(_overridesFile, 'utf8')); } catch { return {}; } +} +function _saveOverrides(map) { + try { + fs.mkdirSync(CONFIG.dataPath, { recursive: true }); + fs.writeFileSync(_overridesFile, JSON.stringify(map, null, 2), 'utf8'); + } catch (e) { console.error('Failed to save romaji overrides:', e.message); } +} + +// GET /api/romaji-overrides — returns the full kanji→romaji map +app.get('/api/romaji-overrides', (_req, res) => res.json(_loadOverrides())); + +// POST /api/romaji-overrides — upsert { kanji, romaji } +app.post('/api/romaji-overrides', (req, res) => { + const { kanji, romaji } = req.body; + if (!kanji?.trim()) return res.status(400).json({ error: 'kanji required' }); + const map = _loadOverrides(); + if (romaji?.trim()) map[kanji.trim()] = romaji.trim(); + else delete map[kanji.trim()]; + _saveOverrides(map); + clearRomajiCache(); // rebuild romaji index so display picks up the change + res.json({ ok: true }); +}); + +// DELETE /api/romaji-overrides/:kanji — remove one entry +app.delete('/api/romaji-overrides/:kanji', (req, res) => { + const kanji = decodeURIComponent(req.params.kanji); + const map = _loadOverrides(); + delete map[kanji]; + _saveOverrides(map); + clearRomajiCache(); + res.json({ ok: true }); +}); + // POST /api/romaji-convert — convert an array of strings to romaji app.post('/api/romaji-convert', (req, res) => { const { texts } = req.body; diff --git a/docker-compose.yml b/docker-compose.yml index 42e5176..7a40738 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,11 +20,13 @@ services: volumes: - ./backend:/app:ro # mount entire backend dir (server + lib/) - ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping + - novasonic-data:/data # writable data dir for overrides, state, etc. ports: - "3700:3700" # route this port through your reverse proxy environment: - PORT=3700 - MUSIC_PATH=${MUSIC_PATH} # same path inside the container + - DATA_PATH=/data - AUDIO_FMT=${AUDIO_FMT:-mp3} - AUDIO_QUALITY=${AUDIO_QUALITY:-0} - MAX_PARALLEL=${MAX_PARALLEL:-2} @@ -38,3 +40,6 @@ services: mkdir -p /deps && cd /deps && npm install express kuromoji && cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js " + +volumes: + novasonic-data: # persistent storage for romaji overrides and app state diff --git a/src/api.js b/src/api.js index 90b3f32..179c433 100644 --- a/src/api.js +++ b/src/api.js @@ -99,19 +99,40 @@ export async function fetchRomajiIndex() { export function invalidateRomajiIndex() { _romajiIndex = null; _romajiMap = null; _artistRomajiMap = null; } // ─── ROMAJI OVERRIDES ─── -// Manual kanji → romaji corrections stored in localStorage. Always take priority over auto-conversion. +// Stored server-side (shared across all clients). Cached in memory per session. +let _overridesCache = null; // null = not loaded yet + export function getRomajiOverrides() { - try { return JSON.parse(localStorage.getItem('ns_romaji_overrides') || '{}'); } catch { return {}; } + return _overridesCache || {}; } -export function setRomajiOverride(kanji, romaji) { - const o = getRomajiOverrides(); - if (romaji?.trim()) o[kanji.trim()] = romaji.trim(); else delete o[kanji.trim()]; - localStorage.setItem('ns_romaji_overrides', JSON.stringify(o)); + +export async function loadRomajiOverrides() { + if (!S.cfg?.backendUrl) return; + try { + const r = await fetch(`${S.cfg.backendUrl}/api/romaji-overrides`); + _overridesCache = r.ok ? await r.json() : {}; + } catch { _overridesCache = {}; } } -export function removeRomajiOverride(kanji) { - const o = getRomajiOverrides(); - delete o[kanji]; - localStorage.setItem('ns_romaji_overrides', JSON.stringify(o)); + +export async function setRomajiOverride(kanji, romaji) { + if (!S.cfg?.backendUrl || !kanji?.trim()) return; + try { + await fetch(`${S.cfg.backendUrl}/api/romaji-overrides`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kanji: kanji.trim(), romaji: romaji?.trim() || '' }), + }); + if (!_overridesCache) _overridesCache = {}; + if (romaji?.trim()) _overridesCache[kanji.trim()] = romaji.trim(); + else delete _overridesCache[kanji.trim()]; + } catch (e) { console.warn('setRomajiOverride failed:', e); } +} + +export async function removeRomajiOverride(kanji) { + if (!S.cfg?.backendUrl || !kanji) return; + try { + await fetch(`${S.cfg.backendUrl}/api/romaji-overrides/${encodeURIComponent(kanji)}`, { method: 'DELETE' }); + if (_overridesCache) delete _overridesCache[kanji]; + } catch (e) { console.warn('removeRomajiOverride failed:', e); } } export function dispArtistById(id, name) { diff --git a/src/main.js b/src/main.js index 20e816b..ff6e651 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ import { S } from './state.js'; import { toast } from './utils.js'; -import { api, fetchRomajiIndex } from './api.js'; +import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js'; import { injectPWA } from './pwa.js'; import { loadSavedTheme } from './themes.js'; import { @@ -96,6 +96,7 @@ async function startApp() { if (bnav) bnav.style.display = ''; absLoadSettingsLibraries(); } + loadRomajiOverrides().catch(() => {}); // fire-and-forget — cache for dispArtist/dispAlbum/dispTitle if (S.cfg.showRomaji) fetchRomajiIndex().catch(() => {}); // fire-and-forget — don't block navigation if(_navLoad() && S._navCursor >= 0 && S._navStack[S._navCursor]) { const last = S._navStack[S._navCursor]; diff --git a/src/player.js b/src/player.js index 1d027bd..9a86778 100644 --- a/src/player.js +++ b/src/player.js @@ -65,6 +65,13 @@ const buildTrackList = () => S.queue.map(_trackToNative); // Single-track JSON for setQueue — ExoPlayer always holds exactly one item. const _nativeSingle = s => JSON.stringify([_trackToNative(s)]); +// Persist the current track to Android SharedPreferences so MusicService can +// pre-warm ExoPlayer on the next cold start before the WebView finishes loading. +function _persistLastPlayed(s) { + if (!HAS_BRIDGE || !s) return; + try { window.NSBridge.saveLastPlayed(JSON.stringify(_trackToNative(s))); } catch (_) {} +} + // Reload native player with just the current song. The single-track model means // ExoPlayer is never responsible for queue order — JS owns that entirely. function _pushQueueIfBridge() { @@ -453,6 +460,7 @@ export function updatePlayerUI(s, _skipMediaSession = false) { document.title = `${s.title} — NovaSonic`; if (!_skipMediaSession) updateMediaSession(s); if(document.getElementById('page-recommendations').classList.contains('active')) loadRecommendations(); + _persistLastPlayed(s); try { localStorage.setItem('ns_last_played', JSON.stringify({ song: { id:s.id, title:s.title, artist:s.artist, album:s.album, albumId:s.albumId, diff --git a/src/ui.js b/src/ui.js index 155d61f..c8e3efc 100644 --- a/src/ui.js +++ b/src/ui.js @@ -92,7 +92,7 @@ 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; } - setRomajiOverride(k, v); + await setRomajiOverride(k, v); document.getElementById('ro-kanji').value = ''; document.getElementById('ro-romaji').value = ''; _renderRomajiOverrides(); @@ -111,8 +111,8 @@ export async function addRomajiOverrideUI() { toast('⚠ Fix-Artist-Sort: ' + e.message); } } -export function removeRomajiOverrideUI(kanji) { - removeRomajiOverride(kanji); +export async function removeRomajiOverrideUI(kanji) { + await removeRomajiOverride(kanji); _renderRomajiOverrides(); } export function openRomajiOverridesModal() {