Build & Push Docker Image / Build & push image (push) Successful in 6m49s
LOSSLESS_ONLY (default true): follow-up tracks are only converted when nothing is lost (MP3 into a FLAC stream). FLAC after MP3 or higher resolution FLAC plays in original quality with a normal track change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
18 KiB
Python
244 lines
18 KiB
Python
"""HTML templates and texts for the bluos-upnp-bridge web UI (English and German)."""
|
||
import json
|
||
|
||
BASE_CSS = """
|
||
:root{--bg:#f4f5f7;--card:#fff;--text:#1d2129;--muted:#6b7280;--line:#e5e7eb;--accent:#2563eb;
|
||
--ok:#16a34a;--warn:#d97706;--err:#dc2626;--chip:#eef2ff;--mono:ui-monospace,SFMono-Regular,Consolas,monospace}
|
||
@media (prefers-color-scheme:dark){:root{--bg:#0f1115;--card:#181b22;--text:#e6e8ec;--muted:#9aa3b2;
|
||
--line:#2a2f3a;--accent:#60a5fa;--chip:#1e293b}}
|
||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);
|
||
font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;
|
||
border-bottom:1px solid var(--line);background:var(--card)}
|
||
header h1{font-size:18px;margin:0}header nav a{color:var(--accent);text-decoration:none;margin-left:14px}
|
||
main{max-width:1100px;margin:0 auto;padding:20px 16px;display:grid;gap:16px;
|
||
grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
|
||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:16px 18px}
|
||
.card h2{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 12px}
|
||
.wide{grid-column:1/-1}.muted{color:var(--muted)}.mono{font-family:var(--mono);font-size:13px}
|
||
.chip{display:inline-block;padding:2px 10px;border-radius:999px;background:var(--chip);font-size:12px;font-weight:600}
|
||
.ok{color:var(--ok)}.warn{color:var(--warn)}.err{color:var(--err)}
|
||
dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;margin:0}dt{color:var(--muted)}dd{margin:0;word-break:break-word}
|
||
button,.btn{font:inherit;border:1px solid var(--line);background:var(--card);color:var(--text);
|
||
border-radius:8px;padding:7px 14px;cursor:pointer}button.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||
button.danger{color:var(--err)}button:disabled{opacity:.5;cursor:default}
|
||
"""
|
||
|
||
TEXT = {
|
||
"en": {
|
||
"locale": "en-GB", "bridge": "BluOS Bridge", "settings": "Settings", "to_status": "Back to status page",
|
||
"now_playing": "Now playing", "nothing": "Nothing selected", "unknown_title": "Unknown title",
|
||
"player": "Bluesound player", "events": "Events", "up_next": "Up next",
|
||
"st_playing": "Playing", "st_paused": "Paused", "st_stopped": "Stopped", "st_ready": "Ready",
|
||
"st_loading": "Loading …", "elsewhere": " (player busy with another source)", "unreachable": "Bridge unreachable",
|
||
"never": "never", "just_now": "just now", "ago_s": "{n} s ago", "ago_min": "{n} min ago",
|
||
"rep_queue": "Queue", "rep_one": "One track", "rep_off": "Off",
|
||
"name": "Name", "address": "Address", "connection": "Connection", "error": "Error", "state": "State",
|
||
"source": "Source", "display": "Display", "format": "Format", "volume": "Volume", "muted": " (muted)",
|
||
"repeat": "Repeat", "version": "Version", "uptime": "Uptime", "announced": "Announced as",
|
||
"music_server": "Music server", "active_as": "active as", "off": "off", "repeat_off": "Repeat off",
|
||
"yes": "yes", "no": "no", "controllers": "Controlling devices", "subscriptions": "Subscriptions (events)",
|
||
"openhome": "OpenHome", "gapless": "Gapless",
|
||
"sec_bridge": "Bridge & music server", "save": "Save",
|
||
"saved_in": "Settings are stored in", "restart_hint1": "Fields marked",
|
||
"restart_hint2": "take effect after restarting the bridge.", "restart": "Restart", "restart_pending":
|
||
"Restart pending", "tools": "Tools", "test_player": "Test player connection",
|
||
"test_server": "Test music server login", "pause": "Pause", "resume": "Resume",
|
||
"restart_bridge": "Restart bridge", "confirm_restart":
|
||
"Really restart the bridge? Playback continues, but your app has to reconnect.",
|
||
"not_writable": "Warning: {dir} is not writable, settings can't be saved.",
|
||
"session_expired": "Session expired, please save again.",
|
||
},
|
||
"de": {
|
||
"locale": "de-DE", "bridge": "BluOS-Brücke", "settings": "Einstellungen", "to_status": "Zur Statusseite",
|
||
"now_playing": "Läuft gerade", "nothing": "Nichts ausgewählt", "unknown_title": "Unbekannter Titel",
|
||
"player": "Bluesound-Player", "events": "Ereignisse", "up_next": "Als Nächstes",
|
||
"st_playing": "Spielt", "st_paused": "Pausiert", "st_stopped": "Gestoppt", "st_ready": "Bereit",
|
||
"st_loading": "Lädt …", "elsewhere": " (Player woanders aktiv)", "unreachable": "Brücke nicht erreichbar",
|
||
"never": "nie", "just_now": "gerade eben", "ago_s": "vor {n} s", "ago_min": "vor {n} min",
|
||
"rep_queue": "Warteschlange", "rep_one": "Ein Titel", "rep_off": "Aus",
|
||
"name": "Name", "address": "Adresse", "connection": "Verbindung", "error": "Fehler", "state": "Status",
|
||
"source": "Quelle", "display": "Anzeige", "format": "Format", "volume": "Lautstärke", "muted": " (stumm)",
|
||
"repeat": "Wiederholen", "version": "Version", "uptime": "Läuft seit", "announced": "Angekündigt als",
|
||
"music_server": "Musikserver", "active_as": "aktiv als", "off": "aus", "repeat_off": "Wiederholung aus",
|
||
"yes": "ja", "no": "nein", "controllers": "Steuernde Geräte", "subscriptions": "Abos (Events)",
|
||
"openhome": "OpenHome", "gapless": "Lückenlos",
|
||
"sec_bridge": "Brücke & Musikserver", "save": "Speichern",
|
||
"saved_in": "Gespeichert wird in", "restart_hint1": "Felder mit",
|
||
"restart_hint2": "werden erst nach einem Neustart der Brücke aktiv.", "restart": "Neustart",
|
||
"restart_pending": "Neustart ausstehend", "tools": "Werkzeuge", "test_player": "Player-Verbindung testen",
|
||
"test_server": "Musikserver-Anmeldung testen", "pause": "Pause", "resume": "Fortsetzen",
|
||
"restart_bridge": "Brücke neu starten", "confirm_restart":
|
||
"Brücke wirklich neu starten? Die laufende Wiedergabe bleibt, die App muss sich aber neu verbinden.",
|
||
"not_writable": "Achtung: {dir} ist nicht beschreibbar, Einstellungen können nicht gespeichert werden.",
|
||
"session_expired": "Sitzung abgelaufen, bitte erneut speichern.",
|
||
},
|
||
}
|
||
|
||
# English labels/help for the settings page (German ones live in bridge.SETTING_FIELDS)
|
||
FIELDS_EN = {
|
||
"UI_LANG": ("Language of the web UI", "en = English, de = Deutsch."),
|
||
"FRIENDLY_NAME": ("Name on the network", "How the bridge shows up in your app. Empty = player name + "
|
||
"“(BluOS Bridge)”."),
|
||
"BLUOS_HOST": ("BluOS player (IP or hostname)", ""),
|
||
"BLUOS_PORT": ("BluOS API port", "Default: 11000"),
|
||
"ADVERTISE_IP": ("IP address of the bridge", "The bridge announces itself on this address via SSDP."),
|
||
"SSDP_INTERVAL": ("SSDP announcement every … seconds", ""),
|
||
"SERVER_TYPE": ("Server type", "subsonic = classic Subsonic (token auth). opensubsonic = Airsonic Pulse, "
|
||
"Navidrome & co. (API key, without key password auth – then “serve "
|
||
"streams through the bridge” must be on)."),
|
||
"SUBSONIC_URL": ("Music server (as seen from the bridge)", "e.g. http://192.168.1.20:4040 or "
|
||
"https://music.example.com"),
|
||
"SUBSONIC_ALIASES": ("Further hostnames of the server", "Comma-separated. Hostnames your app uses for the same "
|
||
"server, e.g. its public domain."),
|
||
"CLIENT_NAME": ("Client name towards the music server", "Shown by the server, e.g. “user@BluOS Player”. "
|
||
"{player} = name of the BluOS player."),
|
||
"SUBSONIC_USER": ("Service user", "Local user (no LDAP) that may only stream."),
|
||
"SUBSONIC_PASSWORD": ("Password of the service user", "Leave empty to keep it."),
|
||
"SUBSONIC_API_KEY": ("API key of the service user (opensubsonic only)", "Used instead of user/password. "
|
||
"Leave empty to keep it."),
|
||
"REPEAT_OFF": ("Switch BluOS repeat off on playback", "Recommended: your app handles repeat and shuffle itself."),
|
||
"PROXY_STREAMS": ("Serve music server streams through the bridge", "Recommended: enables seeking and a clean "
|
||
"track end. The player must reach the bridge."),
|
||
"GAPLESS": ("Gapless playback (experimental)", "Joins tracks into one continuous stream – for live albums, "
|
||
"mixes and audio plays. Other formats are converted with ffmpeg."),
|
||
"LOSSLESS_ONLY": ("Gapless only without quality loss", "Converts follow-up tracks only if nothing is lost "
|
||
"(MP3 → FLAC). A FLAC after an MP3 is not turned "
|
||
"into MP3 but played in original quality with a normal "
|
||
"track change."),
|
||
"FADE_MS": ("Fade in/out (milliseconds)", "Soft fades on pause, stop and track change. 0 = off."),
|
||
"OPENHOME": ("Offer OpenHome", "Your app hands the whole queue to the bridge – music keeps playing while "
|
||
"the phone sleeps."),
|
||
"LOG_LEVEL": ("Log level", ""),
|
||
}
|
||
|
||
STATUS_HTML = """<!doctype html><html lang="__LANG__"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{bridge}}</title>
|
||
<style>__CSS__
|
||
.now{display:flex;gap:16px;align-items:center}.cover{width:120px;height:120px;border-radius:10px;object-fit:cover;
|
||
background:var(--line);flex:none}.title{font-size:20px;font-weight:650;margin:0}.artist{margin:2px 0}
|
||
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;margin-top:12px}
|
||
.bar>div{height:100%;background:var(--accent);width:0;transition:width .9s linear}
|
||
.times{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-top:4px}
|
||
.log{max-height:380px;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.55}
|
||
.log div{padding:1px 0;border-bottom:1px dashed var(--line);white-space:pre-wrap;word-break:break-word}
|
||
.log .t{color:var(--muted);margin-right:8px}.log .WARNING{color:var(--warn)}.log .ERROR{color:var(--err)}
|
||
</style></head><body>
|
||
<header><h1 id="name">{{bridge}}</h1><nav><span class="muted mono" id="ver"></span><a href="/settings">{{settings}}</a></nav></header>
|
||
<main>
|
||
<section class="card wide"><h2>{{now_playing}}</h2>
|
||
<div class="now"><img class="cover" id="cover" alt="">
|
||
<div style="flex:1;min-width:0"><span class="chip" id="state">–</span>
|
||
<p class="title" id="title">{{nothing}}</p><p class="artist" id="artist"></p><p class="muted" id="album"></p></div></div>
|
||
<div class="bar"><div id="bar"></div></div><div class="times"><span id="pos">0:00</span><span id="dur">0:00</span></div>
|
||
<p class="muted" id="next" style="margin:10px 0 0"></p>
|
||
</section>
|
||
<section class="card"><h2>{{player}}</h2><dl id="player"></dl></section>
|
||
<section class="card"><h2>{{bridge}}</h2><dl id="bridge"></dl></section>
|
||
<section class="card wide"><h2>{{events}}</h2><div class="log" id="log"></div></section>
|
||
</main>
|
||
<script>
|
||
const T=__T__;
|
||
const $=id=>document.getElementById(id);
|
||
const STATES={PLAYING:[T.st_playing,"ok"],PAUSED_PLAYBACK:[T.st_paused,"warn"],STOPPED:[T.st_stopped,""],
|
||
NO_MEDIA_PRESENT:[T.st_ready,""],TRANSITIONING:[T.st_loading,"warn"]};
|
||
const fmt=s=>{s=Math.max(0,Math.floor(s||0));const h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=String(s%60).padStart(2,"0");
|
||
return h?`${h}:${String(m).padStart(2,"0")}:${x}`:`${m}:${x}`};
|
||
const ago=t=>{if(!t)return T.never;const d=Math.round(Date.now()/1000-t);
|
||
return d<5?T.just_now:d<120?T.ago_s.replace("{n}",d):T.ago_min.replace("{n}",Math.round(d/60))};
|
||
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
||
const dl=(el,rows)=>{el.innerHTML=rows.map(([k,v])=>`<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")};
|
||
let coverKey=null;
|
||
async function status(){
|
||
try{const s=await (await fetch("/api/status",{cache:"no-store"})).json();
|
||
$("name").textContent=s.name;document.title=s.name;$("ver").textContent="v"+s.version;
|
||
const [label,cls]=STATES[s.transport_state]||[s.transport_state,""];
|
||
$("state").textContent=label+(s.owned||!s.current?"":T.elsewhere);$("state").className="chip "+cls;
|
||
const c=s.current;$("title").textContent=c?(c.title||T.unknown_title):T.nothing;
|
||
$("artist").textContent=c?c.artist:"";$("album").textContent=c?c.album:"";
|
||
const key=c&&c.song_id?c.song_id:null;if(key!==coverKey){coverKey=key;$("cover").src=key?"/cover?id="+encodeURIComponent(key):"";}
|
||
$("cover").style.visibility=key?"visible":"hidden";
|
||
const d=s.duration||0;$("pos").textContent=fmt(s.position);$("dur").textContent=d?fmt(d):"–";
|
||
$("bar").style.width=d?Math.min(100,s.position/d*100)+"%":"0";
|
||
$("next").textContent=s.next?`${T.up_next}: ${s.next.title}${s.next.artist?" – "+s.next.artist:""}`:"";
|
||
const p=s.player||{},rep={"0":T.rep_queue,"1":T.rep_one,"2":T.rep_off}[p.repeat]||"–";
|
||
dl($("player"),[[T.name,esc(p.name||s.bluos_host)],[T.address,`<span class="mono">${esc(s.bluos_host)}:${s.bluos_port}</span>`],
|
||
[T.connection,s.player_error?`<span class="err">${T.error}: ${esc(s.player_error)}</span>`:`<span class="ok">ok</span> <span class="muted">(${ago(s.player_ok)})</span>`],
|
||
[T.state,esc(p.state||"–")],[T.source,esc(p.service||"–")],[T.display,esc([p.title1,p.title2].filter(Boolean).join(" – ")||"–")],
|
||
[T.format,esc(p.streamFormat||"–")],[T.volume,`${s.volume}${s.mute?T.muted:""}`],[T.repeat,rep]]);
|
||
const cps=Object.entries(s.control_points||{}).sort((a,b)=>b[1]-a[1]).map(([ip,t])=>`<span class="mono">${esc(ip)}</span> <span class="muted">${ago(t)}</span>`).join("<br>")||"–";
|
||
dl($("bridge"),[[T.version,esc(s.version)],[T.uptime,fmt(s.uptime)],[T.announced,`<span class="mono">${esc(s.location)}</span>`],
|
||
[T.music_server,s.subsonic.enabled?`<span class="ok">${T.active_as}</span> <span class="mono">${esc(s.subsonic.user)}</span>`:`<span class="warn">${T.off}</span>`],
|
||
[T.openhome,s.openhome?T.yes:T.no],[T.gapless,s.gapless?T.yes:T.no],
|
||
[T.repeat_off,s.repeat_off?T.yes:T.no],[T.controllers,cps],[T.subscriptions,s.subscriptions]]);
|
||
}catch(e){$("state").textContent=T.unreachable;$("state").className="chip err";}
|
||
}
|
||
let lastId=0;
|
||
async function logs(){
|
||
try{const r=await (await fetch("/api/log?after="+lastId,{cache:"no-store"})).json();const box=$("log");
|
||
const atBottom=box.scrollHeight-box.scrollTop-box.clientHeight<30;
|
||
for(const l of r){lastId=l.id;const d=document.createElement("div");
|
||
d.innerHTML=`<span class="t">${new Date(l.t*1000).toLocaleTimeString(T.locale)}</span><span class="${l.level}">${esc(l.msg)}</span>`;box.appendChild(d);}
|
||
while(box.children.length>300)box.removeChild(box.firstChild);if(atBottom)box.scrollTop=box.scrollHeight;
|
||
}catch(e){}
|
||
}
|
||
status();logs();setInterval(status,1000);setInterval(logs,2000);
|
||
</script></body></html>
|
||
""".replace("__CSS__", BASE_CSS)
|
||
|
||
SETTINGS_HTML = """<!doctype html><html lang="__LANG__"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{{settings}} · {{bridge}}</title>
|
||
<style>__CSS__
|
||
main{grid-template-columns:1fr}form{display:grid;gap:14px}
|
||
label{display:grid;gap:4px;font-weight:600}label small{font-weight:400;color:var(--muted)}
|
||
input[type=text],input[type=number],input[type=password],select{font:inherit;padding:8px 10px;border-radius:8px;
|
||
border:1px solid var(--line);background:var(--bg);color:var(--text);width:100%}
|
||
.check{display:flex;gap:10px;align-items:flex-start;font-weight:600}.check input{margin-top:5px}
|
||
.restart{color:var(--warn);font-weight:400;font-size:12px}
|
||
.notice{padding:10px 14px;border-radius:8px;border:1px solid var(--line)}.notice.ok{border-color:var(--ok)}.notice.err{border-color:var(--err)}
|
||
.actions{display:flex;flex-wrap:wrap;gap:10px}#out{white-space:pre-wrap;margin-top:12px}
|
||
</style></head><body>
|
||
<header><h1>{{settings}}</h1><nav><a href="/">{{to_status}}</a></nav></header>
|
||
<main>
|
||
__NOTICE__
|
||
<section class="card"><h2>{{sec_bridge}}</h2>
|
||
<form method="post" action="/settings"><input type="hidden" name="csrf" value="__CSRF__">
|
||
__FIELDS__
|
||
<div class="actions"><button class="primary" type="submit">{{save}}</button></div>
|
||
<p class="muted" style="margin:0">{{saved_in}} <span class="mono">__FILE__</span>. {{restart_hint1}}
|
||
<span class="restart">{{restart}}</span> {{restart_hint2}}</p>
|
||
</form></section>
|
||
<section class="card"><h2>{{tools}}</h2>
|
||
<div class="actions">
|
||
<button data-a="test_player">{{test_player}}</button>
|
||
<button data-a="test_subsonic">{{test_server}}</button>
|
||
<button data-a="pause">{{pause}}</button><button data-a="play">{{resume}}</button>
|
||
<button class="danger" data-a="restart">{{restart_bridge}}</button></div>
|
||
<div id="out" class="mono muted"></div></section>
|
||
</main>
|
||
<script>
|
||
const T=__T__;
|
||
document.querySelectorAll("button[data-a]").forEach(b=>b.addEventListener("click",async()=>{
|
||
const a=b.dataset.a;if(a==="restart"&&!confirm(T.confirm_restart))return;
|
||
const out=document.getElementById("out");out.textContent="…";b.disabled=true;
|
||
try{const r=await fetch("/api/action",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":"__CSRF__"},body:JSON.stringify({action:a})});
|
||
const j=await r.json();out.textContent=(j.ok?"✔ ":"✖ ")+j.message;out.className="mono "+(j.ok?"ok":"err");
|
||
if(a==="restart"&&j.ok)setTimeout(()=>location.reload(),6000);
|
||
}catch(e){out.textContent="✖ "+e;out.className="mono err";}finally{b.disabled=false;}
|
||
}));
|
||
</script></body></html>
|
||
""".replace("__CSS__", BASE_CSS)
|
||
|
||
|
||
def texts(lang):
|
||
return TEXT.get(lang, TEXT["en"])
|
||
|
||
|
||
def localize(template, lang):
|
||
"""Fill {{key}} placeholders and the JS text table for the given language."""
|
||
t = texts(lang)
|
||
out = template.replace("__LANG__", lang if lang in TEXT else "en").replace("__T__", json.dumps(t))
|
||
for key, value in t.items():
|
||
out = out.replace("{{" + key + "}}", value)
|
||
return out
|