Add forward skip to mini player, home-screen app support, cover fallback
build / check (push) Successful in 8s
build / image (push) Successful in 33s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 16:06:06 -04:00
1 parent 1df566d85d
commit 7f59c6d27b
7 files changed
+41 -10

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080 ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
WORKDIR /app WORKDIR /app
COPY server.py index.html admin.html ./ COPY server.py index.html admin.html manifest.webmanifest icon-180.png icon-192.png icon-512.png ./
USER app USER app
VOLUME /data VOLUME /data
EXPOSE 8080 EXPOSE 8080
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

+17 -8
View File
@@ -8,11 +8,17 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Audiobooks"> <meta name="apple-mobile-web-app-title" content="Audiobooks">
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icon-180.png">
<title>Audiobook Player</title> <title>Audiobook Player</title>
<style> <style>
:root{--bg:#f6f6f4;--surf:#fff;--surf2:#ececea;--fg:#16161a;--mut:#74747c;--acc:#e8590c;--acc2:#ff8a3d;--bd:rgba(0,0,0,.08);--sh:0 8px 30px rgba(0,0,0,.12)} :root{--bg:#f6f6f4;--surf:#fff;--surf2:#ececea;--fg:#16161a;--mut:#74747c;--acc:#e8590c;--acc2:#ff8a3d;--bd:rgba(0,0,0,.08);--sh:0 8px 30px rgba(0,0,0,.12)}
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--surf:#1a1a1f;--surf2:#26262d;--fg:#f2f2f4;--mut:#8f8f99;--acc:#ff7a2f;--acc2:#ffb067;--bd:rgba(255,255,255,.08);--sh:0 8px 30px rgba(0,0,0,.5)}} @media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--surf:#1a1a1f;--surf2:#26262d;--fg:#f2f2f4;--mut:#8f8f99;--acc:#ff7a2f;--acc2:#ffb067;--bd:rgba(255,255,255,.08);--sh:0 8px 30px rgba(0,0,0,.5)}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent} *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}
html{-webkit-text-size-adjust:100%;overscroll-behavior-y:none}
button,a,.card,input[type=range]{touch-action:manipulation}
button,.card,.ib,.pill{-webkit-user-select:none;user-select:none}
html,body{margin:0} html,body{margin:0}
body{background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;padding-bottom:calc(100px + env(safe-area-inset-bottom))} body{background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;padding-bottom:calc(100px + env(safe-area-inset-bottom))}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0} button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
@@ -63,7 +69,7 @@ h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
#mini b,#mini span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #mini b,#mini span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#mini span{color:var(--mut);font-size:12.5px} #mini span{color:var(--mut);font-size:12.5px}
#mini .mbar{position:absolute;left:0;bottom:0;height:3px;background:var(--acc);width:0} #mini .mbar{position:absolute;left:0;bottom:0;height:3px;background:var(--acc);width:0}
.ib{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;font-size:22px;flex:none} .ib{position:relative;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;font-size:22px;flex:none}
.ib:hover{background:var(--surf2)} .ib:hover{background:var(--surf2)}
.pp{background:var(--acc)!important;color:#fff;box-shadow:0 4px 14px color-mix(in srgb,var(--acc) 50%,transparent)} .pp{background:var(--acc)!important;color:#fff;box-shadow:0 4px 14px color-mix(in srgb,var(--acc) 50%,transparent)}
@@ -85,7 +91,7 @@ input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;bac
.times{display:flex;justify-content:space-between;color:var(--mut);font-size:12px;font-variant-numeric:tabular-nums} .times{display:flex;justify-content:space-between;color:var(--mut);font-size:12px;font-variant-numeric:tabular-nums}
.ctl{display:flex;justify-content:center;align-items:center;gap:18px;margin:14px 0} .ctl{display:flex;justify-content:center;align-items:center;gap:18px;margin:14px 0}
.ctl .ib{width:56px;height:56px;font-size:28px;position:relative} .ctl .ib{width:56px;height:56px;font-size:28px;position:relative}
.ctl .ib small{position:absolute;font-size:10px;font-weight:800;top:50%;left:50%;transform:translate(-50%,-38%)} .ib small{position:absolute;font-size:10px;font-weight:800;top:50%;left:50%;transform:translate(-50%,-38%)}
.ctl .pp{width:76px;height:76px;font-size:34px} .ctl .pp{width:76px;height:76px;font-size:34px}
.opts{display:flex;justify-content:center;gap:10px;margin:6px 0 14px} .opts{display:flex;justify-content:center;gap:10px;margin:6px 0 14px}
.pill{background:var(--surf);border:1px solid var(--bd);border-radius:99px;padding:8px 16px;font-weight:600;font-size:13.5px} .pill{background:var(--surf);border:1px solid var(--bd);border-radius:99px;padding:8px 16px;font-weight:600;font-size:13.5px}
@@ -133,7 +139,8 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
<div id="mini" class="hide" role="button" aria-label="Open player"> <div id="mini" class="hide" role="button" aria-label="Open player">
<img id="mimg" alt=""><div class="meta"><b id="mtitle"></b><span id="msub"></span></div> <img id="mimg" alt=""><div class="meta"><b id="mtitle"></b><span id="msub"></span></div>
<button class="ib" id="mback" aria-label="Back 30 seconds"><svg><use href="#i-back"/></svg></button> <button class="ib" id="mback" aria-label="Back 30 seconds"><svg><use href="#i-back"/></svg><small>30</small></button>
<button class="ib" id="mfwd" aria-label="Forward 30 seconds"><svg><use href="#i-fwd"/></svg><small>30</small></button>
<button class="ib pp" id="mplay" aria-label="Play or pause"><svg><use href="#i-play"/></svg></button> <button class="ib pp" id="mplay" aria-label="Play or pause"><svg><use href="#i-play"/></svg></button>
<div class="mbar" id="mbar"></div> <div class="mbar" id="mbar"></div>
</div> </div>
@@ -272,7 +279,8 @@ async function play(b, open) {
saveProgress(); saveProgress();
current = b; current = b;
$('mini').classList.remove('hide'); $('mini').classList.remove('hide');
for (const [i, t] of [['mimg', 'src'], ['bigcov', 'src']]) $(i).src = cover(b); const fallback = 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue(title(b))} 55% 40%)"/><stop offset="1" stop-color="hsl(${hue(title(b)) + 40} 55% 28%)"/></linearGradient></defs><rect width="100" height="100" fill="url(#g)"/><text x="50" y="62" font-size="40" font-family="sans-serif" font-weight="700" text-anchor="middle" fill="#fff">${(title(b)[0] || '?').replace(/[<&]/g, '')}</text></svg>`);
for (const i of ['mimg', 'bigcov']) { $(i).onerror = () => { $(i).onerror = null; $(i).src = fallback; }; $(i).src = cover(b); }
$('fullbg').style.backgroundImage = `url("${cover(b)}")`; $('fullbg').style.backgroundImage = `url("${cover(b)}")`;
$('mtitle').textContent = $('ftitle').textContent = title(b); $('mtitle').textContent = $('ftitle').textContent = title(b);
$('msub').textContent = $('fsub').textContent = author(b); $('msub').textContent = $('fsub').textContent = author(b);
@@ -287,7 +295,7 @@ async function play(b, open) {
const ms = navigator.mediaSession; const ms = navigator.mediaSession;
ms.metadata = new MediaMetadata({ title: title(b), artist: author(b), artwork: [{ src: cover(b) }] }); ms.metadata = new MediaMetadata({ title: title(b), artist: author(b), artwork: [{ src: cover(b) }] });
ms.setActionHandler('seekbackward', () => skip(-30)); ms.setActionHandler('seekforward', () => skip(30)); ms.setActionHandler('seekbackward', () => skip(-30)); ms.setActionHandler('seekforward', () => skip(30));
ms.setActionHandler('previoustrack', () => jump(-1)); ms.setActionHandler('nexttrack', () => jump(1)); ms.setActionHandler('seekto', e => { audio.currentTime = e.seekTime; });
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause()); ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
} }
} }
@@ -319,7 +327,7 @@ function jump(d) {
const toggle = () => audio.paused ? audio.play() : audio.pause(); const toggle = () => audio.paused ? audio.play() : audio.pause();
for (const id of ['play', 'mplay']) $(id).onclick = e => { e.stopPropagation(); toggle(); }; for (const id of ['play', 'mplay']) $(id).onclick = e => { e.stopPropagation(); toggle(); };
for (const id of ['back', 'mback']) $(id).onclick = e => { e.stopPropagation(); skip(-30); }; for (const id of ['back', 'mback']) $(id).onclick = e => { e.stopPropagation(); skip(-30); };
$('fwd').onclick = () => skip(30); $('pch').onclick = () => jump(-1); $('nch').onclick = () => jump(1); for (const id of ['fwd', 'mfwd']) $(id).onclick = e => { e.stopPropagation(); skip(30); }; $('pch').onclick = () => jump(-1); $('nch').onclick = () => jump(1);
audio.onplay = () => { ico('play', 'pause'); ico('mplay', 'pause'); }; audio.onplay = () => { ico('play', 'pause'); ico('mplay', 'pause'); };
audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play'); saveProgress(); }; audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play'); saveProgress(); };
audio.onended = () => { if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); }; audio.onended = () => { if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); };
@@ -352,11 +360,12 @@ async function pull() {
} catch {} } catch {}
} }
function saveProgress() { if (current && audio.duration) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); } function saveProgress() { if (current && audio.duration) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
let lastSave = 0; let lastSave = 0, lastPos = 0;
audio.ontimeupdate = () => { audio.ontimeupdate = () => {
const d = audio.duration || 0, t = audio.currentTime; const d = audio.duration || 0, t = audio.currentTime;
$('seek').max = d || 1; $('seek').value = t; $('seek').style.setProperty('--p', d ? t / d * 100 + '%' : '0%'); $('seek').max = d || 1; $('seek').value = t; $('seek').style.setProperty('--p', d ? t / d * 100 + '%' : '0%');
$('mbar').style.width = d ? t / d * 100 + '%' : '0'; $('mbar').style.width = d ? t / d * 100 + '%' : '0';
if (d && 'mediaSession' in navigator && navigator.mediaSession.setPositionState && Date.now() - lastPos > 1000) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(t, d), playbackRate: audio.playbackRate }); } catch {} }
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt((d - t) / audio.playbackRate); $('cur').textContent = fmt(t); $('left').textContent = '-' + fmt((d - t) / audio.playbackRate);
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveProgress(); } if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveProgress(); }
let i = -1; chapters.forEach((c, k) => { if (t >= c.start) i = k; }); let i = -1; chapters.forEach((c, k) => { if (t >= c.start) i = k; });
+15
View File
@@ -0,0 +1,15 @@
{
"name": "Audiobooks",
"short_name": "Audiobooks",
"description": "Listen to your OpenAudible library",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0f0f12",
"theme_color": "#0f0f12",
"icons": [
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"}
]
}
+8 -1
View File
@@ -39,6 +39,10 @@ F_LEGACY = os.path.join(DATA, "progress.json") # single-user file from the
COOKIE = "oa_session" COOKIE = "oa_session"
SESSION_DAYS = 90 SESSION_DAYS = 90
# Files the browser fetches without a session (home-screen icon and manifest).
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/icon-180.png": "image/png",
"/icon-192.png": "image/png", "/icon-512.png": "image/png"}
# Only read-only endpoints needed for listening are reachable; admin commands/import are not. # Only read-only endpoints needed for listening are reachable; admin commands/import are not.
ALLOWED = [re.compile(p) for p in ( ALLOWED = [re.compile(p) for p in (
r"^/api/books$", r"^/api/books$",
@@ -153,7 +157,7 @@ def cli(argv):
# ---------- login page ---------- # ---------- login page ----------
LOGIN = """<!doctype html><html lang=en><meta charset=utf-8> LOGIN = """<!doctype html><html lang=en><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover"> <meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name=theme-color content="#0f0f12"><title>Sign in</title> <meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><title>Sign in</title>
<style> <style>
:root{color-scheme:light dark;--bg:#f6f6f4;--s:#fff;--fg:#16161a;--m:#74747c;--a:#e8590c;--b:rgba(0,0,0,.1)} :root{color-scheme:light dark;--bg:#f6f6f4;--s:#fff;--fg:#16161a;--m:#74747c;--a:#e8590c;--b:rgba(0,0,0,.1)}
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--s:#1a1a1f;--fg:#f2f2f4;--m:#8f8f99;--a:#ff7a2f;--b:rgba(255,255,255,.1)}} @media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--s:#1a1a1f;--fg:#f2f2f4;--m:#8f8f99;--a:#ff7a2f;--b:rgba(255,255,255,.1)}}
@@ -217,6 +221,9 @@ class Handler(BaseHTTPRequestHandler):
return self.send_body(200, LOGIN, "text/html; charset=utf-8") return self.send_body(200, LOGIN, "text/html; charset=utf-8")
if path == "/logout": if path == "/logout":
return self.logout() return self.logout()
if path in PUBLIC:
with open(os.path.join(HERE, path[1:]), "rb") as f:
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "public, max-age=86400")])
user = self.user() user = self.user()
if not user: if not user:
return self.deny(path) return self.deny(path)