Add forward skip to mini player, home-screen app support, cover fallback
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1df566d85d
commit
7f59c6d27b
7 files changed
+41
-10
No files matched your search
+1
-1
@@ -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
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
+17
-8
@@ -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; });
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user