feat: server-side romaji overrides (shared list); preload ExoPlayer from SharedPreferences on cold start
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
b924f01b2a
commit
36a0b38eca
9 files changed
+116
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
+31
-10
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user