Files
audiobook-maker-pro-v4.2/templates/public_home.html

1833 lines
65 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Audiobook Library</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700;900&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
font-family: 'Inter', sans-serif;
margin: 0;
background: #f5e9d6;
min-height: 100vh;
color: #3e2723;
}
/* Header */
.library-header {
background: linear-gradient(135deg, #4a2c2a 0%, #6b4226 100%);
color: #f5e9d6;
padding: 24px 32px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
position: sticky;
top: 0;
z-index: 100;
}
.header-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.library-title {
font-family: 'Playfair Display', serif;
font-weight: 900;
font-size: 1.8rem;
margin: 0;
display: flex;
align-items: center;
gap: 12px;
}
.library-title i { color: #f0c97a; }
.header-actions {
display: flex;
gap: 12px;
align-items: center;
}
.search-box {
background: rgba(255,255,255,0.1);
border: 1.5px solid rgba(255,255,255,0.25);
color: #f5e9d6;
border-radius: 24px;
padding: 8px 18px 8px 40px;
min-width: 240px;
font-size: 0.9rem;
position: relative;
}
.search-wrapper {
position: relative;
}
.search-wrapper i {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: rgba(245,233,214,0.6);
font-size: 0.9rem;
}
.search-box::placeholder { color: rgba(245,233,214,0.5); }
.search-box:focus {
outline: none;
border-color: #f0c97a;
background: rgba(255,255,255,0.15);
}
.btn-login-link {
background: rgba(255,255,255,0.15);
border: 1.5px solid rgba(255,255,255,0.3);
color: #f5e9d6;
padding: 8px 20px;
border-radius: 24px;
font-weight: 600;
font-size: 0.88rem;
text-decoration: none;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn-login-link:hover {
background: rgba(255,255,255,0.25);
color: #fff;
}
/* Bookcase container */
.bookcase-container {
max-width: 1400px;
margin: 0 auto;
padding: 40px 24px;
}
.library-intro {
text-align: center;
margin-bottom: 32px;
}
.library-intro h2 {
font-family: 'Playfair Display', serif;
font-size: 2.2rem;
font-weight: 700;
color: #4a2c2a;
margin-bottom: 8px;
}
.library-intro p {
color: #6b4226;
font-size: 1.05rem;
opacity: 0.85;
}
/* Bookcase shelf */
.bookcase {
background: linear-gradient(180deg, #c8a87b 0%, #a67c52 100%);
border-radius: 16px;
padding: 24px;
box-shadow: 0 12px 40px rgba(74,44,42,0.3), inset 0 2px 4px rgba(255,255,255,0.2);
position: relative;
}
.shelf {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
padding: 20px 16px 36px;
position: relative;
border-bottom: 8px solid #6b4226;
box-shadow: 0 6px 0 #5a3520, 0 8px 12px rgba(0,0,0,0.2);
margin-bottom: 24px;
border-radius: 6px;
}
.shelf:last-child {
margin-bottom: 0;
}
/* Book card */
.book-card {
cursor: pointer;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: bottom center;
position: relative;
}
.book-card:hover {
transform: translateY(-12px) scale(1.04);
}
.book-cover {
width: 100%;
aspect-ratio: 2 / 3;
border-radius: 4px 8px 8px 4px;
overflow: hidden;
box-shadow:
-2px 2px 0 rgba(0,0,0,0.1),
-4px 4px 0 rgba(0,0,0,0.08),
4px 6px 16px rgba(0,0,0,0.3);
position: relative;
background: linear-gradient(135deg, #2c3e50, #4a6278);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 16px;
color: white;
}
.book-cover::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: linear-gradient(90deg, rgba(0,0,0,0.3), transparent);
}
.book-cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
}
.book-cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.85));
z-index: 1;
}
.book-cover-content {
position: relative;
z-index: 2;
}
.book-cover-default {
background: linear-gradient(135deg, #2c3e50 0%, #4a6278 100%);
}
.book-cover-default::after {
content: '';
position: absolute;
top: 12px;
right: 12px;
left: 12px;
bottom: 12px;
border: 2px solid rgba(255,255,255,0.2);
border-radius: 2px;
z-index: 1;
}
.book-title {
font-family: 'Playfair Display', serif;
font-size: 1rem;
font-weight: 700;
line-height: 1.25;
margin-bottom: 4px;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-author {
font-size: 0.75rem;
opacity: 0.85;
font-weight: 500;
font-style: italic;
}
.book-meta {
margin-top: 12px;
padding: 0 4px;
text-align: center;
}
.book-meta-title {
font-size: 0.85rem;
font-weight: 600;
color: #4a2c2a;
margin-bottom: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-meta-stats {
font-size: 0.72rem;
color: #6b4226;
opacity: 0.7;
display: flex;
justify-content: center;
gap: 8px;
align-items: center;
}
.book-meta-stats i { font-size: 0.7rem; }
/* Empty state */
.empty-state {
text-align: center;
padding: 80px 24px;
color: #6b4226;
}
.empty-state i {
font-size: 4rem;
opacity: 0.4;
margin-bottom: 16px;
}
.empty-state h3 {
font-family: 'Playfair Display', serif;
font-weight: 700;
margin-bottom: 8px;
}
/* Loading */
.loading-state {
text-align: center;
padding: 80px 24px;
color: #6b4226;
}
/* ============================================
Book Detail / Share Modal
============================================= */
.book-modal-overlay {
position: fixed;
inset: 0;
background: rgba(30, 18, 12, 0.78);
backdrop-filter: blur(6px);
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
overflow-y: auto;
}
.book-modal-overlay.visible {
display: flex;
animation: modalFadeIn 0.25s ease;
}
@keyframes modalFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.book-modal {
background: #fdf6ea;
border-radius: 18px;
max-width: 440px;
width: 100%;
box-shadow: 0 30px 70px rgba(0,0,0,0.45);
position: relative;
animation: modalSlideUp 0.3s cubic-bezier(0.34, 1.3, 0.64, 1);
border: 1px solid rgba(107, 66, 38, 0.15);
max-height: calc(100vh - 48px);
overflow-y: auto;
}
@keyframes modalSlideUp {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.book-modal-header {
padding: 22px 28px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.book-modal-header h3 {
font-family: 'Playfair Display', serif;
font-weight: 700;
font-size: 1.25rem;
color: #4a2c2a;
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
padding-right: 12px;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-modal-close {
background: rgba(107, 66, 38, 0.1);
border: none;
color: #6b4226;
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 1.1rem;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.book-modal-close:hover { background: rgba(107, 66, 38, 0.22); }
.book-modal-body {
padding: 18px 28px 28px;
}
.book-modal-top {
display: flex;
gap: 18px;
margin-bottom: 22px;
}
.book-modal-cover {
width: 96px;
height: 144px;
flex-shrink: 0;
border-radius: 4px 8px 8px 4px;
overflow: hidden;
box-shadow: 4px 6px 16px rgba(0,0,0,0.25);
background: linear-gradient(135deg, #2c3e50, #4a6278);
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 10px;
color: white;
}
.book-modal-cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.book-modal-cover .cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 40%, rgba(0,0,0,0.85));
}
.book-modal-cover-text {
position: relative;
z-index: 2;
font-family: 'Playfair Display', serif;
font-size: 0.72rem;
font-weight: 700;
line-height: 1.2;
text-shadow: 0 1px 3px rgba(0,0,0,0.6);
}
.book-modal-info {
flex: 1;
min-width: 0;
}
.book-modal-info .bm-title {
font-family: 'Playfair Display', serif;
font-weight: 700;
font-size: 1.3rem;
color: #2c1810;
line-height: 1.2;
margin-bottom: 6px;
}
.book-modal-info .bm-meta {
font-size: 0.82rem;
color: #6b4226;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.book-modal-info .bm-meta i { opacity: 0.7; }
.book-modal-info .bm-desc {
font-size: 0.85rem;
color: #5a4636;
line-height: 1.5;
margin-top: 8px;
}
.book-modal-actions {
display: flex;
gap: 10px;
margin-bottom: 22px;
}
.btn-read {
flex: 0 0 auto;
background: linear-gradient(135deg, #c0392b, #a93226);
color: white;
border: none;
padding: 11px 26px;
border-radius: 10px;
font-weight: 700;
font-size: 0.92rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(192, 57, 43, 0.35);
}
.btn-read:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(192, 57, 43, 0.45);
color: white;
}
.btn-copy-link {
flex: 1;
background: rgba(107, 66, 38, 0.1);
color: #6b4226;
border: 1.5px solid rgba(107, 66, 38, 0.25);
padding: 11px 18px;
border-radius: 10px;
font-weight: 600;
font-size: 0.88rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s;
}
.btn-copy-link:hover { background: rgba(107, 66, 38, 0.18); }
.btn-copy-link.copied {
background: #2e7d32;
color: white;
border-color: #2e7d32;
}
.share-section-label {
font-size: 0.78rem;
font-weight: 700;
color: #6b4226;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.share-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 24px;
}
.share-btn {
border: none;
color: white;
padding: 8px 14px;
border-radius: 8px;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
transition: all 0.2s;
}
.share-btn:hover { transform: translateY(-2px); filter: brightness(1.08); color: white; }
.share-btn i { font-size: 0.95rem; }
.share-facebook { background: #1877f2; }
.share-twitter { background: #000000; }
.share-pinterest{ background: #e60023; }
.share-whatsapp { background: #25d366; }
.share-linkedin { background: #0a66c2; }
.share-email { background: #6b4226; }
.qr-section {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: rgba(107, 66, 38, 0.06);
border-radius: 12px;
border: 1px solid rgba(107, 66, 38, 0.12);
}
.qr-code-box {
width: 110px;
height: 110px;
flex-shrink: 0;
background: white;
border-radius: 8px;
padding: 7px;
display: flex;
align-items: center;
justify-content: center;
}
.qr-code-box img, .qr-code-box canvas {
width: 100% !important;
height: 100% !important;
}
.qr-text {
font-size: 0.85rem;
color: #5a4636;
line-height: 1.5;
}
.qr-text strong {
color: #4a2c2a;
display: block;
margin-bottom: 2px;
font-size: 0.9rem;
}
/* Responsive */
@media (max-width: 768px) {
.header-container {
flex-direction: column;
align-items: stretch;
}
.header-actions {
justify-content: space-between;
}
.search-box {
min-width: 0;
width: 100%;
}
.shelf {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
padding: 16px 12px 28px;
}
.library-intro h2 {
font-size: 1.6rem;
}
.bookcase {
padding: 16px;
}
.book-modal-actions { flex-wrap: wrap; }
.qr-section { flex-direction: column; text-align: center; }
}
@media (max-width: 480px) {
.shelf {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.book-title { font-size: 0.85rem; }
.book-author { font-size: 0.7rem; }
}
/* ============================================
Audiobook Player Overlay (Audible-style)
============================================= */
.player-overlay {
position: fixed;
inset: 0;
background: rgba(20, 12, 8, 0.92);
backdrop-filter: blur(10px);
z-index: 1200;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.player-overlay.visible {
display: flex;
animation: modalFadeIn 0.25s ease;
}
.player-card {
background: #fdf6ea;
border-radius: 20px;
width: 100%;
max-width: 460px;
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
box-shadow: 0 30px 70px rgba(0,0,0,0.5);
overflow: hidden;
animation: modalSlideUp 0.3s cubic-bezier(0.34, 1.3, 0.64, 1);
}
.player-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
flex-shrink: 0;
}
.player-top-bar .pt-logo {
color: #c0392b;
font-size: 1.4rem;
}
.player-close {
background: rgba(107, 66, 38, 0.1);
border: none;
color: #6b4226;
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 1.05rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.player-close:hover { background: rgba(107, 66, 38, 0.22); }
.player-body {
padding: 0 24px 20px;
overflow-y: auto;
flex: 1;
display: flex;
flex-direction: column;
}
.player-cover {
width: 180px;
height: 180px;
margin: 0 auto 18px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
background: linear-gradient(135deg, #2c3e50, #4a6278);
position: relative;
display: flex;
align-items: flex-end;
padding: 14px;
color: white;
flex-shrink: 0;
}
.player-cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.player-cover .cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, transparent 45%, rgba(0,0,0,0.8));
}
.player-cover-text {
position: relative;
z-index: 2;
font-family: 'Playfair Display', serif;
font-weight: 700;
font-size: 0.95rem;
line-height: 1.2;
text-shadow: 0 2px 4px rgba(0,0,0,0.6);
}
.player-book-title {
text-align: center;
font-family: 'Playfair Display', serif;
font-weight: 700;
font-size: 1.15rem;
color: #2c1810;
margin-bottom: 2px;
}
.player-book-author {
text-align: center;
font-size: 0.82rem;
color: #6b4226;
font-style: italic;
margin-bottom: 14px;
}
/* Subtitle box with word highlight */
.player-subtitle {
background: rgba(107, 66, 38, 0.07);
border: 1px solid rgba(107, 66, 38, 0.14);
border-radius: 12px;
padding: 16px 18px;
font-size: 1.05rem;
line-height: 1.7;
color: #3e2b1d;
max-height: 160px;
overflow-y: auto;
margin-bottom: 16px;
min-height: 70px;
}
.player-subtitle .pw {
cursor: pointer;
border-radius: 3px;
transition: background 0.15s, color 0.15s;
}
.player-subtitle .pw:hover { background: #f0e2cd; }
.player-subtitle .pw.current-word {
color: #c0392b;
font-weight: 700;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.player-subtitle .pw.current-sentence {
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
background-color: #f3e3c8;
border-radius: 4px;
}
.player-subtitle-empty {
color: #a08a72;
font-style: italic;
text-align: center;
}
.player-section-label {
text-align: center;
font-size: 0.78rem;
color: #8a6d52;
margin-bottom: 10px;
font-weight: 500;
}
.player-section-label strong { color: #4a2c2a; }
/* Seek bar */
.player-seek {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: rgba(107, 66, 38, 0.2);
cursor: pointer;
margin-bottom: 6px;
}
.player-seek::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #c0392b;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
}
.player-seek::-moz-range-thumb {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: #c0392b;
cursor: pointer;
}
.player-time-row {
display: flex;
justify-content: space-between;
font-size: 0.72rem;
color: #8a6d52;
margin-bottom: 14px;
font-variant-numeric: tabular-nums;
}
/* Controls */
.player-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 22px;
margin-bottom: 6px;
}
.player-ctrl-btn {
background: none;
border: none;
color: #4a2c2a;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s, opacity 0.2s;
}
.player-ctrl-btn:hover { transform: scale(1.12); }
.player-ctrl-btn:active { transform: scale(0.95); }
.player-ctrl-btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none; }
.player-ctrl-btn svg { width: 30px; height: 30px; fill: currentColor; }
.player-ctrl-btn .label {
font-size: 0.62rem;
font-weight: 700;
}
.player-play-btn {
width: 66px;
height: 66px;
border-radius: 50%;
background: linear-gradient(135deg, #c0392b, #a93226);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 18px rgba(192, 57, 43, 0.4);
transition: transform 0.15s;
position: relative;
}
.player-play-btn:hover { transform: scale(1.06); }
.player-play-btn:active { transform: scale(0.96); }
.player-play-btn svg { width: 32px; height: 32px; fill: white; }
.player-play-spinner {
width: 30px; height: 30px;
border: 3px solid rgba(255,255,255,0.35);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: none;
}
.player-play-btn.loading svg { display: none; }
.player-play-btn.loading .player-play-spinner { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
.player-counter {
text-align: center;
font-size: 0.72rem;
color: #a08a72;
margin-top: 10px;
}
@media (max-width: 480px) {
.player-cover { width: 150px; height: 150px; }
.player-controls { gap: 16px; }
}
/* Footer */
.library-footer {
text-align: center;
padding: 32px 24px;
color: #6b4226;
font-size: 0.85rem;
opacity: 0.7;
}
</style>
</head>
<body>
<header class="library-header">
<div class="header-container">
<h1 class="library-title">
<i class="bi bi-book-half"></i>
Audiobook Library
</h1>
<div class="header-actions">
<div class="search-wrapper">
<i class="bi bi-search"></i>
<input type="text" class="search-box" id="searchInput"
placeholder="Search books..." oninput="filterBooks(this.value)">
</div>
<a href="/login" class="btn-login-link">
<i class="bi bi-person-circle"></i>
<span>Sign In</span>
</a>
</div>
</div>
</header>
<main class="bookcase-container">
<div class="library-intro">
<h2>Discover Stories That Speak</h2>
<p>Browse our collection of interactive audiobooks</p>
</div>
<div id="bookcaseContainer">
<div class="loading-state">
<div class="spinner-border" role="status" style="color: #6b4226;"></div>
<p class="mt-3">Loading library...</p>
</div>
</div>
</main>
<footer class="library-footer">
<p>Powered by Audiobook Maker Pro v4.2</p>
</footer>
<!-- Book Detail / Share Modal -->
<div class="book-modal-overlay" id="bookModalOverlay" onclick="handleOverlayClick(event)">
<div class="book-modal" id="bookModal">
<div class="book-modal-header">
<h3 id="bmHeaderTitle">Book</h3>
<button class="book-modal-close" onclick="closeBookModal()" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="book-modal-body">
<div class="book-modal-top">
<div class="book-modal-cover" id="bmCover"></div>
<div class="book-modal-info">
<div class="bm-title" id="bmTitle"></div>
<div class="bm-meta" id="bmAuthor"></div>
<div class="bm-meta" id="bmExtraMeta"></div>
<div class="bm-desc" id="bmDesc"></div>
</div>
</div>
<div class="book-modal-actions">
<a class="btn-read" id="bmReadBtn" href="#">
<i class="bi bi-book"></i> Read
</a>
<button class="btn-read" id="bmPlayBtn" onclick="openPlayer()"
style="background:linear-gradient(135deg,#6b4226,#5a3520);box-shadow:0 4px 12px rgba(107,66,38,0.35);">
<i class="bi bi-headphones"></i> Play Audio
</button>
<button class="btn-copy-link" id="bmCopyBtn" onclick="copyBookLink()">
<i class="bi bi-link-45deg"></i> <span id="bmCopyText">Copy Link</span>
</button>
</div>
<div class="share-section-label">
<i class="bi bi-share-fill"></i> Share
</div>
<div class="share-buttons" id="bmShareButtons"></div>
<div class="qr-section">
<div class="qr-code-box" id="bmQrCode"></div>
<div class="qr-text">
<strong>Scan to open</strong>
Open this audiobook on any device by scanning the QR code.
</div>
</div>
</div>
</div>
</div>
<!-- Audiobook Player Overlay (Audible-style) -->
<div class="player-overlay" id="playerOverlay" onclick="handlePlayerOverlayClick(event)">
<div class="player-card">
<div class="player-top-bar">
<i class="bi bi-soundwave pt-logo"></i>
<button class="player-close" onclick="closePlayer()" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="player-body">
<div class="player-cover" id="plCover"></div>
<div class="player-book-title" id="plTitle"></div>
<div class="player-book-author" id="plAuthor"></div>
<div class="player-section-label" id="plSectionLabel"></div>
<div class="player-subtitle" id="plSubtitle">
<div class="player-subtitle-empty">Press play to begin listening.</div>
</div>
<input type="range" class="player-seek" id="plSeek" min="0" max="100" value="0" step="0.1">
<div class="player-time-row">
<span id="plCurrentTime">00:00</span>
<span id="plDuration">00:00</span>
</div>
<div class="player-controls">
<button class="player-ctrl-btn" id="plPrevBtn" onclick="playerPrev()" title="Previous">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="player-play-btn" id="plPlayBtn" onclick="playerTogglePlay()">
<svg id="plPlayIcon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="plPauseIcon" viewBox="0 0 24 24" style="display:none;"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<div class="player-play-spinner"></div>
</button>
<button class="player-ctrl-btn" id="plNextBtn" onclick="playerNext()" title="Next">
<svg viewBox="0 0 24 24"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
</button>
</div>
<div class="player-counter" id="plCounter"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
let allBooks = [];
let currentBook = null;
let currentBookUrl = '';
async function loadBooks() {
try {
const resp = await fetch('/api/public/books');
const data = await resp.json();
allBooks = data.books || [];
renderBookcase(allBooks);
} catch (e) {
document.getElementById('bookcaseContainer').innerHTML = `
<div class="empty-state">
<i class="bi bi-exclamation-circle"></i>
<h3>Failed to load library</h3>
<p>Please refresh the page to try again.</p>
</div>
`;
}
}
function renderBookcase(books) {
const container = document.getElementById('bookcaseContainer');
if (!books || books.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="bi bi-book"></i>
<h3>No books yet</h3>
<p>The library is being curated. Check back soon!</p>
</div>
`;
return;
}
const booksPerShelf = 6;
const shelves = [];
for (let i = 0; i < books.length; i += booksPerShelf) {
shelves.push(books.slice(i, i + booksPerShelf));
}
let html = '<div class="bookcase">';
for (const shelf of shelves) {
html += '<div class="shelf">';
for (const book of shelf) {
html += renderBookCard(book);
}
html += '</div>';
}
html += '</div>';
container.innerHTML = html;
}
function renderBookCard(book) {
const thumbnailHtml = book.thumbnail_data
? `<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
<div class="book-cover-overlay"></div>`
: '';
const coverClass = book.thumbnail_data ? '' : 'book-cover-default';
const author = book.author || 'Unknown Author';
return `
<div class="book-card" onclick="openBook(${book.id})" title="${escapeHtml(book.name)}">
<div class="book-cover ${coverClass}">
${thumbnailHtml}
<div class="book-cover-content">
<div class="book-title">${escapeHtml(book.name)}</div>
<div class="book-author">by ${escapeHtml(author)}</div>
</div>
</div>
</div>
`;
}
// ============================================
// Book Modal (detail + share + QR)
// ============================================
function openBook(bookId) {
const book = allBooks.find(b => b.id === bookId);
if (!book) {
// fallback: open directly
window.location.href = `/read/${bookId}`;
return;
}
currentBook = book;
currentBookUrl = `${window.location.origin}/read/${book.id}`;
const author = book.author || 'Unknown Author';
// Header + title
document.getElementById('bmHeaderTitle').textContent = book.name;
document.getElementById('bmTitle').textContent = book.name;
// Cover
const coverEl = document.getElementById('bmCover');
if (book.thumbnail_data) {
coverEl.innerHTML = `
<img src="data:image/${book.thumbnail_format};base64,${book.thumbnail_data}" alt="${escapeHtml(book.name)}">
<div class="cover-overlay"></div>
`;
} else {
coverEl.innerHTML = `<div class="book-modal-cover-text">${escapeHtml(book.name)}</div>`;
}
// Author
document.getElementById('bmAuthor').innerHTML =
`<i class="bi bi-person"></i> ${escapeHtml(author)}`;
// Extra meta (category + chapters)
const metaParts = [];
if (book.category) {
metaParts.push(`<i class="bi bi-tag"></i> ${escapeHtml(book.category)}`);
}
if (book.chapter_count) {
metaParts.push(`<i class="bi bi-collection"></i> ${book.chapter_count} sections`);
}
const extraEl = document.getElementById('bmExtraMeta');
if (metaParts.length) {
extraEl.innerHTML = metaParts.join('<span style="opacity:0.4;">•</span>');
extraEl.style.display = 'flex';
} else {
extraEl.style.display = 'none';
}
// Description
const descEl = document.getElementById('bmDesc');
if (book.description) {
descEl.textContent = book.description;
descEl.style.display = 'block';
} else {
descEl.style.display = 'none';
}
// Read button
document.getElementById('bmReadBtn').href = `/read/${book.id}`;
// Reset copy button
const copyBtn = document.getElementById('bmCopyBtn');
copyBtn.classList.remove('copied');
document.getElementById('bmCopyText').textContent = 'Copy Link';
// Share buttons
renderShareButtons(book);
// QR code
renderQRCode(currentBookUrl);
// Show
const overlay = document.getElementById('bookModalOverlay');
overlay.classList.add('visible');
document.body.style.overflow = 'hidden';
}
function renderShareButtons(book) {
const url = encodeURIComponent(currentBookUrl);
const title = encodeURIComponent(book.name + (book.author ? ` by ${book.author}` : ''));
const desc = encodeURIComponent(book.description || 'Listen to this interactive audiobook');
const thumb = book.thumbnail_data
? '' // Pinterest needs a public image URL; base64 won't work, so skip media param
: '';
const shares = [
{
cls: 'share-facebook', icon: 'facebook', label: 'Facebook',
href: `https://www.facebook.com/sharer/sharer.php?u=${url}`
},
{
cls: 'share-twitter', icon: 'twitter-x', label: 'X',
href: `https://twitter.com/intent/tweet?url=${url}&text=${title}`
},
{
cls: 'share-pinterest', icon: 'pinterest', label: 'Pinterest',
href: `https://pinterest.com/pin/create/button/?url=${url}&description=${title}`
},
{
cls: 'share-whatsapp', icon: 'whatsapp', label: 'WhatsApp',
href: `https://api.whatsapp.com/send?text=${title}%20${url}`
},
{
cls: 'share-linkedin', icon: 'linkedin', label: 'LinkedIn',
href: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`
},
{
cls: 'share-email', icon: 'envelope-fill', label: 'Email',
href: `mailto:?subject=${title}&body=${desc}%0A%0A${url}`
}
];
const container = document.getElementById('bmShareButtons');
container.innerHTML = shares.map(s => `
<a class="share-btn ${s.cls}" href="${s.href}" target="_blank" rel="noopener"
onclick="trackShare(event, '${s.label}')">
<i class="bi bi-${s.icon}"></i> ${s.label}
</a>
`).join('');
}
function trackShare(event, platform) {
// Email uses mailto (same tab); social opens in popup window for nicer UX
if (platform !== 'Email') {
event.preventDefault();
const href = event.currentTarget.href;
window.open(href, '_blank', 'width=600,height=600,noopener');
}
}
function renderQRCode(url) {
const qrBox = document.getElementById('bmQrCode');
qrBox.innerHTML = '';
if (typeof QRCode === 'undefined') {
qrBox.innerHTML = '<span style="font-size:0.7rem;color:#999;">QR unavailable</span>';
return;
}
try {
new QRCode(qrBox, {
text: url,
width: 96,
height: 96,
colorDark: '#2c1810',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
} catch (e) {
qrBox.innerHTML = '<span style="font-size:0.7rem;color:#999;">QR error</span>';
}
}
async function copyBookLink() {
const copyBtn = document.getElementById('bmCopyBtn');
const copyText = document.getElementById('bmCopyText');
try {
await navigator.clipboard.writeText(currentBookUrl);
} catch (e) {
// Fallback for older browsers / non-HTTPS
const ta = document.createElement('textarea');
ta.value = currentBookUrl;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (err) {}
document.body.removeChild(ta);
}
copyBtn.classList.add('copied');
copyText.textContent = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyText.textContent = 'Copy Link';
}, 2000);
}
function closeBookModal() {
const overlay = document.getElementById('bookModalOverlay');
overlay.classList.remove('visible');
document.body.style.overflow = '';
}
function handleOverlayClick(event) {
if (event.target === document.getElementById('bookModalOverlay')) {
closeBookModal();
}
}
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeBookModal();
});
function filterBooks(query) {
query = query.toLowerCase().trim();
if (!query) {
renderBookcase(allBooks);
return;
}
const filtered = allBooks.filter(b =>
b.name.toLowerCase().includes(query) ||
(b.author && b.author.toLowerCase().includes(query)) ||
(b.description && b.description.toLowerCase().includes(query))
);
renderBookcase(filtered);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
// ============================================
// Audiobook Player (Audible-style)
// ============================================
let playerState = {
book: null,
tracks: [], // playable blocks with audio
currentIndex: -1,
audio: null,
audioUrl: null,
wordSpans: [],
wordMap: [],
sentenceData: [],
animFrameId: null,
lastWordSpan: null,
lastSentenceSpans: [],
loadingPromise: null,
isSeeking: false
};
async function openPlayer() {
if (!currentBook) return;
// Show overlay immediately with loading state
const overlay = document.getElementById('playerOverlay');
overlay.classList.add('visible');
document.body.style.overflow = 'hidden';
// Populate cover/title/author
const coverEl = document.getElementById('plCover');
if (currentBook.thumbnail_data) {
coverEl.innerHTML = `
<img src="data:image/${currentBook.thumbnail_format};base64,${currentBook.thumbnail_data}" alt="">
<div class="cover-overlay"></div>
`;
} else {
coverEl.innerHTML = `<div class="player-cover-text">${escapeHtml(currentBook.name)}</div>`;
}
document.getElementById('plTitle').textContent = currentBook.name;
document.getElementById('plAuthor').textContent =
currentBook.author ? `by ${currentBook.author}` : '';
document.getElementById('plSubtitle').innerHTML =
'<div class="player-subtitle-empty">Loading audiobook…</div>';
document.getElementById('plSectionLabel').textContent = '';
document.getElementById('plCounter').textContent = '';
// Fetch full book metadata (chapters + blocks + transcription)
try {
const resp = await fetch(`/api/public/books/${currentBook.id}`);
if (!resp.ok) throw new Error('Failed to load');
const fullBook = await resp.json();
playerState.book = fullBook;
// Build flat list of playable tracks (blocks with audio)
const tracks = [];
for (const ch of fullBook.chapters) {
for (const block of ch.blocks) {
if (block.has_audio && block.block_type !== 'image') {
tracks.push({
blockId: block.id,
content: block.content || '',
transcription: block.transcription || [],
sectionTitle: ch.title,
sectionNumber: ch.chapter_number
});
}
}
}
playerState.tracks = tracks;
if (tracks.length === 0) {
document.getElementById('plSubtitle').innerHTML =
'<div class="player-subtitle-empty">No audio available for this book.</div>';
return;
}
playerState.currentIndex = -1;
loadTrack(0, { autoPlay: false });
} catch (e) {
console.error(e);
document.getElementById('plSubtitle').innerHTML =
'<div class="player-subtitle-empty">Failed to load audiobook.</div>';
}
}
function loadTrack(index, opts = {}) {
if (index < 0 || index >= playerState.tracks.length) return;
if (index === playerState.currentIndex && playerState.audio) {
if (opts.autoPlay) playerState.audio.play().catch(() => {});
return;
}
// Tear down previous audio
releasePlayerAudio();
playerState.currentIndex = index;
const track = playerState.tracks[index];
// Render subtitle words
renderPlayerSubtitle(track);
buildPlayerSync(track);
// Section label + counter
document.getElementById('plSectionLabel').innerHTML =
`<strong>${escapeHtml(track.sectionTitle || 'Section')}</strong>`;
document.getElementById('plCounter').textContent =
`Track ${index + 1} of ${playerState.tracks.length}`;
// Update prev/next disabled state
document.getElementById('plPrevBtn').disabled = (index === 0);
document.getElementById('plNextBtn').disabled = (index === playerState.tracks.length - 1);
// Reset seek/time
document.getElementById('plSeek').value = 0;
document.getElementById('plCurrentTime').textContent = '00:00';
document.getElementById('plDuration').textContent = '00:00';
// Load audio
const autoPlay = opts.autoPlay !== false;
ensurePlayerAudio(track).then(() => {
if (autoPlay && playerState.currentIndex === index) {
playerState.audio.play().catch(() => {});
}
}).catch(err => {
console.error(err);
setPlayerLoading(false);
});
}
function ensurePlayerAudio(track) {
setPlayerLoading(true);
playerState.loadingPromise = (async () => {
const resp = await fetch(`/api/public/books/${playerState.book.id}/audio/${track.blockId}`);
if (!resp.ok) throw new Error('Audio fetch failed');
const data = await resp.json();
if (data.error || !data.audio_data) throw new Error(data.error || 'No audio');
const blob = base64ToBlob(data.audio_data, `audio/${data.audio_format || 'mp3'}`);
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
return new Promise((resolve, reject) => {
const onReady = () => {
audio.removeEventListener('error', onErr);
playerState.audio = audio;
playerState.audioUrl = url;
wirePlayerAudio(audio);
setPlayerLoading(false);
resolve();
};
const onErr = () => {
audio.removeEventListener('canplay', onReady);
try { URL.revokeObjectURL(url); } catch (e) {}
setPlayerLoading(false);
reject(new Error('Audio load error'));
};
audio.addEventListener('canplay', onReady, { once: true });
audio.addEventListener('error', onErr, { once: true });
audio.preload = 'auto';
audio.load();
});
})();
return playerState.loadingPromise;
}
function wirePlayerAudio(audio) {
audio.addEventListener('play', () => {
updatePlayerPlayIcon(true);
startPlayerHighlight();
});
audio.addEventListener('pause', () => {
updatePlayerPlayIcon(false);
stopPlayerHighlight();
});
audio.addEventListener('ended', () => {
stopPlayerHighlight();
clearPlayerHighlights();
if (playerState.currentIndex < playerState.tracks.length - 1) {
loadTrack(playerState.currentIndex + 1, { autoPlay: true });
} else {
updatePlayerPlayIcon(false);
}
});
audio.addEventListener('loadedmetadata', () => {
document.getElementById('plDuration').textContent = formatTime(audio.duration);
});
audio.addEventListener('timeupdate', () => {
if (playerState.isSeeking) return;
const cur = audio.currentTime;
const dur = audio.duration || 0;
document.getElementById('plCurrentTime').textContent = formatTime(cur);
if (dur > 0) {
document.getElementById('plSeek').value = (cur / dur) * 100;
}
});
}
function setPlayerLoading(loading) {
const btn = document.getElementById('plPlayBtn');
btn.classList.toggle('loading', loading);
}
function updatePlayerPlayIcon(playing) {
document.getElementById('plPlayIcon').style.display = playing ? 'none' : 'block';
document.getElementById('plPauseIcon').style.display = playing ? 'block' : 'none';
}
function playerTogglePlay() {
if (!playerState.audio) {
if (playerState.tracks.length > 0) loadTrack(0, { autoPlay: true });
return;
}
if (playerState.audio.paused) {
playerState.audio.play().catch(() => {});
} else {
playerState.audio.pause();
}
}
function playerPrev() {
if (playerState.currentIndex > 0) {
loadTrack(playerState.currentIndex - 1, { autoPlay: true });
}
}
function playerNext() {
if (playerState.currentIndex < playerState.tracks.length - 1) {
loadTrack(playerState.currentIndex + 1, { autoPlay: true });
}
}
// Seek bar interaction
(function initSeek() {
const seek = document.getElementById('plSeek');
const onSeekStart = () => { playerState.isSeeking = true; };
const onSeekEnd = () => {
if (playerState.audio && playerState.audio.duration) {
playerState.audio.currentTime =
(seek.value / 100) * playerState.audio.duration;
}
playerState.isSeeking = false;
};
seek.addEventListener('input', () => {
if (playerState.audio && playerState.audio.duration) {
const t = (seek.value / 100) * playerState.audio.duration;
document.getElementById('plCurrentTime').textContent = formatTime(t);
}
});
seek.addEventListener('mousedown', onSeekStart);
seek.addEventListener('touchstart', onSeekStart, { passive: true });
seek.addEventListener('change', onSeekEnd);
seek.addEventListener('mouseup', onSeekEnd);
seek.addEventListener('touchend', onSeekEnd);
})();
// Subtitle rendering + sync
function renderPlayerSubtitle(track) {
const container = document.getElementById('plSubtitle');
container.innerHTML = '';
playerState.wordSpans = [];
const div = document.createElement('div');
// Strip markdown image refs for cleaner subtitle text
const text = track.content.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
div.innerHTML = (typeof marked !== 'undefined')
? marked.parse(text, { breaks: true, gfm: true })
: text;
(function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
const words = node.textContent.split(/(\s+)/);
const frag = document.createDocumentFragment();
words.forEach(part => {
if (part.trim().length > 0) {
const span = document.createElement('span');
span.className = 'pw';
span.textContent = part;
span.dataset.wordIdx = playerState.wordSpans.length;
playerState.wordSpans.push(span);
frag.appendChild(span);
} else {
frag.appendChild(document.createTextNode(part));
}
});
node.parentNode.replaceChild(frag, node);
} else if (node.nodeType === Node.ELEMENT_NODE) {
Array.from(node.childNodes).forEach(processNode);
}
})(div);
while (div.firstChild) container.appendChild(div.firstChild);
container.scrollTop = 0;
// Click-to-seek on words
container.onclick = (e) => {
const span = e.target.closest('.pw');
if (!span) return;
const idx = parseInt(span.dataset.wordIdx, 10);
const ai = playerState.wordMap[idx];
if (ai === undefined) return;
const track = playerState.tracks[playerState.currentIndex];
const ts = track.transcription[ai];
if (!ts) return;
if (playerState.audio) {
playerState.audio.currentTime = ts.start;
playerState.audio.play().catch(() => {});
} else {
loadTrack(playerState.currentIndex, { autoPlay: true });
}
};
}
function buildPlayerSync(track) {
const transcription = track.transcription || [];
const wordSpans = playerState.wordSpans;
playerState.wordMap = new Array(wordSpans.length).fill(undefined);
let ai = 0;
wordSpans.forEach((span, i) => {
const tw = span.textContent.toLowerCase().replace(/[^\w]/g, '');
for (let o = 0; o < 5; o++) {
if (ai + o >= transcription.length) break;
const aw = (transcription[ai + o].word || '').toLowerCase().replace(/[^\w]/g, '');
if (tw === aw) { playerState.wordMap[i] = ai + o; ai += o + 1; return; }
}
});
playerState.sentenceData = [];
let buf = [], si = 0;
wordSpans.forEach((s, i) => {
buf.push(s);
if (/[.!?]["'\u201D\u2019]?$/.test(s.textContent.trim())) {
let sT = 0, eT = 0;
for (let k = si; k <= i; k++) if (playerState.wordMap[k] !== undefined) { sT = transcription[playerState.wordMap[k]].start; break; }
for (let k = i; k >= si; k--) if (playerState.wordMap[k] !== undefined) { eT = transcription[playerState.wordMap[k]].end; break; }
if (eT > sT) playerState.sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT });
buf = []; si = i + 1;
}
});
if (buf.length > 0) {
let sT = 0, eT = 0;
for (let k = si; k < wordSpans.length; k++) if (playerState.wordMap[k] !== undefined) { sT = transcription[playerState.wordMap[k]].start; break; }
for (let k = wordSpans.length - 1; k >= si; k--) if (playerState.wordMap[k] !== undefined) { eT = transcription[playerState.wordMap[k]].end; break; }
if (eT > sT) playerState.sentenceData.push({ spans: [...buf], startTime: sT, endTime: eT });
}
}
function startPlayerHighlight() {
cancelAnimationFrame(playerState.animFrameId);
playerState.animFrameId = requestAnimationFrame(playerHighlightLoop);
}
function stopPlayerHighlight() {
cancelAnimationFrame(playerState.animFrameId);
}
function playerHighlightLoop() {
const audio = playerState.audio;
if (!audio || audio.paused) return;
const t = audio.currentTime;
const track = playerState.tracks[playerState.currentIndex];
const transcription = track ? (track.transcription || []) : [];
const ai = transcription.findIndex(w => t >= w.start && t < w.end);
if (ai !== -1) {
const ti = playerState.wordMap.findIndex(i => i === ai);
if (ti !== -1) {
const sp = playerState.wordSpans[ti];
if (sp !== playerState.lastWordSpan) {
if (playerState.lastWordSpan) playerState.lastWordSpan.classList.remove('current-word');
sp.classList.add('current-word');
// keep highlighted word in view inside subtitle box
const box = document.getElementById('plSubtitle');
const spTop = sp.offsetTop, spBottom = spTop + sp.offsetHeight;
if (spTop < box.scrollTop || spBottom > box.scrollTop + box.clientHeight) {
box.scrollTo({ top: spTop - box.clientHeight / 2, behavior: 'smooth' });
}
playerState.lastWordSpan = sp;
}
}
}
const sent = playerState.sentenceData.find(s => t >= s.startTime && t <= s.endTime);
if (sent && sent.spans !== playerState.lastSentenceSpans) {
playerState.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence'));
sent.spans.forEach(s => s.classList.add('current-sentence'));
playerState.lastSentenceSpans = sent.spans;
}
playerState.animFrameId = requestAnimationFrame(playerHighlightLoop);
}
function clearPlayerHighlights() {
if (playerState.lastWordSpan) playerState.lastWordSpan.classList.remove('current-word');
playerState.lastSentenceSpans.forEach(s => s.classList.remove('current-sentence'));
playerState.lastWordSpan = null;
playerState.lastSentenceSpans = [];
}
function releasePlayerAudio() {
stopPlayerHighlight();
clearPlayerHighlights();
if (playerState.audio) {
try { playerState.audio.pause(); } catch (e) {}
playerState.audio = null;
}
if (playerState.audioUrl) {
try { URL.revokeObjectURL(playerState.audioUrl); } catch (e) {}
playerState.audioUrl = null;
}
playerState.loadingPromise = null;
setPlayerLoading(false);
updatePlayerPlayIcon(false);
}
function closePlayer() {
releasePlayerAudio();
const overlay = document.getElementById('playerOverlay');
overlay.classList.remove('visible');
// book modal still open underneath, keep body locked
const bookModalVisible = document.getElementById('bookModalOverlay').classList.contains('visible');
if (!bookModalVisible) document.body.style.overflow = '';
}
function handlePlayerOverlayClick(event) {
if (event.target === document.getElementById('playerOverlay')) {
closePlayer();
}
}
function formatTime(sec) {
if (!sec || isNaN(sec)) return '00:00';
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function base64ToBlob(b64, mime) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return new Blob([arr], { type: mime });
}
// Escape closes player first, then book modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const playerVisible = document.getElementById('playerOverlay').classList.contains('visible');
if (playerVisible) { closePlayer(); }
}
});
loadBooks();
</script>
</body>
</html>